Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/e2e/src/tests/schema-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ describe.skipIf(!ready)('Schema Sync · History (SQLite)', () => {
expect(await driver.locator('[data-testid="lokee-inspector-column-mutations"]').isVisible()).toBe(
true
);
expect(await driver.locator('[data-testid="lokee-inspector-revert-1"]').isVisible()).toBe(true);
// The roadmap keeps the version that created the table reachable even
// though the panel now defaults to the versions that touched it.
expect(await driver.locator('[data-testid="lokee-inspector-version-1"]').isVisible()).toBe(true);
});
});
138 changes: 138 additions & 0 deletions apps/web/src/backend/api/file-browse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* Fox Schema (foxschema)
* Copyright 2024-2026 Huy Phan <huyplb@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*
* The rule these encode: this endpoint names database files and directories,
* and nothing else. Every test that adds a file type is asking whether it
* would still be true.
*/
import { mkdtemp, mkdir, writeFile, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, describe, expect, it } from 'vitest';
import {
browseDirectory,
browseErrorMessage,
isDatabaseFile,
parentOf,
resolveBrowsePath,
} from './file-browse';

const root = await mkdtemp(join(tmpdir(), 'fox-browse-'));

await mkdir(join(root, 'projects'));
await mkdir(join(root, '.hidden'));
await writeFile(join(root, 'app.db'), 'x');
await writeFile(join(root, 'analytics.duckdb'), 'x');
await writeFile(join(root, 'Notes.SQLite3'), 'x');
await writeFile(join(root, 'secrets.env'), 'AWS_SECRET=1');
await writeFile(join(root, 'id_rsa'), 'PRIVATE KEY');
await writeFile(join(root, 'dump.sql'), 'select 1');
await writeFile(join(root, '.env'), 'TOKEN=1');

afterAll(() => {
/* tmpdir is the OS's to clean */
});

describe('isDatabaseFile', () => {
it.each([
['app.db', true],
['app.DB', true],
['store.sqlite', true],
['store.sqlite3', true],
['warehouse.duckdb', true],
['x.ddb', true],
['dump.sql', false],
['secrets.env', false],
['id_rsa', false],
['db', false],
['notes.dbx', false],
])('%s → %s', (name, want) => {
expect(isDatabaseFile(name)).toBe(want);
});
});

describe('browseDirectory', () => {
it('lists directories and database files, and nothing else', async () => {
const result = await browseDirectory(root);
const names = result.entries.map((e) => e.name);
expect(names).toContain('projects');
expect(names).toContain('app.db');
expect(names).toContain('analytics.duckdb');
expect(names).toContain('Notes.SQLite3');
// The point of the filter: a signed-in user cannot use this to enumerate
// keys, dumps or dotfiles.
expect(names).not.toContain('secrets.env');
expect(names).not.toContain('id_rsa');
expect(names).not.toContain('dump.sql');
expect(names).not.toContain('.env');
expect(names).not.toContain('.hidden');
});

it('never returns file contents — only name, size and mtime', async () => {
const file = (await browseDirectory(root)).entries.find((e) => e.name === 'app.db');
expect(Object.keys(file!).sort()).toEqual(['kind', 'modifiedAt', 'name', 'path', 'size']);
});

it('puts directories before files, each sorted case-insensitively', async () => {
const kinds = (await browseDirectory(root)).entries.map((e) => e.kind);
expect(kinds.indexOf('dir')).toBeLessThan(kinds.indexOf('file'));
const files = (await browseDirectory(root)).entries.filter((e) => e.kind === 'file');
expect(files.map((f) => f.name)).toEqual(['analytics.duckdb', 'app.db', 'Notes.SQLite3']);
});

it('lists the containing directory when handed a file', async () => {
// The picker reopens on the last used path, which is a file.
const result = await browseDirectory(join(root, 'app.db'));
expect(result.path).toBe(root);
});

it('reports a parent for a nested directory and null at the root', async () => {
expect((await browseDirectory(join(root, 'projects'))).parent).toBe(root);
expect(parentOf('/')).toBeNull();
});

it('rejects a path with a NUL byte instead of normalizing it', () => {
const home = '/home/someone';
expect(resolveBrowsePath('/etc\0/../../root', home)).toBe(home);
});

it('resolves a relative path against home, not the process cwd', () => {
// cwd is wherever the service was started from — not a place the user has
// any model of.
expect(resolveBrowsePath('data', '/home/someone')).toBe('/home/someone/data');
expect(resolveBrowsePath('', '/home/someone')).toBe('/home/someone');
expect(resolveBrowsePath(undefined, '/home/someone')).toBe('/home/someone');
});

it('follows a symlinked directory', async () => {
const link = join(root, 'linked');
await symlink(join(root, 'projects'), link, 'dir');
expect((await browseDirectory(link)).entries).toEqual([]);
});

it('throws for a missing directory, with a message that names it', async () => {
const missing = join(root, 'nope');
await expect(browseDirectory(missing)).rejects.toThrow();
await expect(
browseDirectory(missing).catch((e: unknown) => browseErrorMessage(e, missing))
).resolves.toContain('No such directory');
});
});

describe('browseErrorMessage', () => {
it.each([
['ENOENT', 'No such directory'],
['EACCES', 'Permission denied'],
['EPERM', 'Permission denied'],
['ENOTDIR', 'Not a directory'],
])('%s reads as "%s"', (code, expected) => {
const err = Object.assign(new Error('raw'), { code });
expect(browseErrorMessage(err, '/some/dir')).toContain(expected);
});

it('falls back to the error message for anything else', () => {
expect(browseErrorMessage(new Error('disk on fire'), '/x')).toBe('disk on fire');
});
});
155 changes: 155 additions & 0 deletions apps/web/src/backend/api/file-browse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* Fox Schema (foxschema)
* Copyright 2024-2026 Huy Phan <huyplb@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*
* Directory listing for the SQLite / DuckDB file picker.
*
* The browser cannot hand the server a real path — an OS file dialog gives a
* `File` with a name and no location — so a database file that lives on the
* machine running Fox Schema can only be picked by listing that machine.
*
* What this deliberately is *not*: a file manager. It returns names, sizes and
* mtimes, never contents, and the only files it names are ones a database
* driver could open. A signed-in user could already reach any of these by
* typing the path into the connection form; this makes that discoverable
* without widening what they can actually do with it.
*/
import { readdir, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, dirname, isAbsolute, resolve, sep } from 'node:path';

/** Extensions a SQLite or DuckDB connection could actually open. */
export const DATABASE_FILE_EXTENSIONS = [
'.db',
'.db3',
'.sqlite',
'.sqlite3',
'.duckdb',
'.ddb',
] as const;

export interface FileBrowseEntry {
name: string;
path: string;
kind: 'dir' | 'file';
/** Files only. */
size?: number;
modifiedAt?: string;
}

export interface FileBrowseResult {
/** The directory that was listed, absolute and normalized. */
path: string;
/** Parent directory, or null at the filesystem root. */
parent: string | null;
/** Where "Home" jumps to, so the client does not have to guess. */
home: string;
entries: FileBrowseEntry[];
/** True when the listing was cut at the cap — the client says so. */
truncated: boolean;
}

/** Directories with thousands of entries are a UI hazard, not a feature. */
const MAX_ENTRIES = 500;

export function isDatabaseFile(name: string): boolean {
const lower = name.toLowerCase();
return DATABASE_FILE_EXTENSIONS.some((ext) => lower.endsWith(ext));
}

/**
* Resolve the requested directory.
*
* A relative or empty path resolves against the home directory rather than the
* server process's cwd: cwd is wherever the service happened to be started
* from, which is not a place the user has any model of.
*/
export function resolveBrowsePath(raw: string | undefined, home = homedir()): string {
const trimmed = (raw ?? '').trim();
if (!trimmed) return home;
// A NUL byte truncates the path inside libc — reject rather than normalize,
// because "/safe\0/../../etc" is two different paths depending on who reads it.
if (trimmed.includes('\0')) return home;
return isAbsolute(trimmed) ? resolve(trimmed) : resolve(home, trimmed);
}

/** Parent of `dir`, or null once dirname stops moving (the root). */
export function parentOf(dir: string): string | null {
const parent = dirname(dir);
return parent === dir ? null : parent;
}

/**
* List one directory: sub-directories, then database files.
*
* Hidden entries are skipped — a `.git` or `.cache` full of nothing openable is
* noise — but a user who types a dotted path can still browse into it, because
* the filter applies to what is listed, not to where you may go.
*/
export async function browseDirectory(rawPath?: string): Promise<FileBrowseResult> {
const home = homedir();
const path = resolveBrowsePath(rawPath, home);

// eslint-disable-next-line security/detect-non-literal-fs-filename -- the user-supplied path IS the feature; it is resolved above, rejected if it carries a NUL, and only ever stat'd or listed, never read or written
const info = await stat(path);
if (!info.isDirectory()) {
// Being handed a file is normal: the picker reopens on the last used path,
// which is a file. List where it lives.
return browseDirectory(dirname(path));
}

// eslint-disable-next-line security/detect-non-literal-fs-filename -- same path, already resolved and validated; listing names is the whole operation
const dirents = await readdir(path, { withFileTypes: true });
const dirs: FileBrowseEntry[] = [];
const files: FileBrowseEntry[] = [];

for (const dirent of dirents) {
if (dirent.name.startsWith('.')) continue;
const full = path.endsWith(sep) ? `${path}${dirent.name}` : `${path}${sep}${dirent.name}`;
if (dirent.isDirectory()) {
dirs.push({ name: dirent.name, path: full, kind: 'dir' });
continue;
}
// A symlink to a database file is still a database file; a symlink to a
// directory is followed on click, by the same stat as any other path.
if (!dirent.isFile() && !dirent.isSymbolicLink()) continue;
if (!isDatabaseFile(dirent.name)) continue;
let size: number | undefined;
let modifiedAt: string | undefined;
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- `full` is a child of the directory just listed, not caller input
const fileInfo = await stat(full);
if (fileInfo.isDirectory()) continue;
size = fileInfo.size;
modifiedAt = fileInfo.mtime.toISOString();
} catch {
// A dangling symlink or a file removed mid-listing: still worth naming,
// just without its details.
}
files.push({ name: dirent.name, path: full, kind: 'file', size, modifiedAt });
}

const byName = (a: FileBrowseEntry, b: FileBrowseEntry) =>
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
dirs.sort(byName);
files.sort(byName);

const all = [...dirs, ...files];
return {
path,
parent: parentOf(path),
home,
entries: all.slice(0, MAX_ENTRIES),
truncated: all.length > MAX_ENTRIES,
};
}

/** Human-readable reason a directory could not be listed. */
export function browseErrorMessage(error: unknown, path: string): string {
const code = (error as NodeJS.ErrnoException | null)?.code;
if (code === 'ENOENT') return `No such directory: ${path}`;
if (code === 'EACCES' || code === 'EPERM') return `Permission denied: ${path}`;
if (code === 'ENOTDIR') return `Not a directory: ${basename(path)}`;
return error instanceof Error ? error.message : 'Failed to list directory';
}
23 changes: 23 additions & 0 deletions apps/web/src/backend/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { isSingleSqlStatement } from './single-statement';
import { AppSettingsStore } from '../modules/app-settings.module';
import { LokeeWeaveStore } from '../modules/lokee-weave.module';
import { rateLimit } from './rate-limit';
import { browseDirectory, browseErrorMessage, resolveBrowsePath } from './file-browse';
import {
runStatements,
clampMaxRows,
Expand Down Expand Up @@ -944,6 +945,28 @@ export function createApiRoutes(connectionModule: ConnectionModule, connectionSt
}
);

// Directory listing for the SQLite / DuckDB file picker. Read-only and
// name-only: it never returns file contents, and only names files a database
// driver could open. `schema.browse` because picking a database file is the
// first step of browsing one.
const fileBrowseLimiter = rateLimit({ windowMs: 60 * 1000, max: 60 });

router.get(
'/files/browse',
fileBrowseLimiter,
requirePermissions('schema.browse'),
async (req: Request, res: Response) => {
const requested = typeof req.query.path === 'string' ? req.query.path : undefined;
try {
res.json(await browseDirectory(requested));
} catch (error: unknown) {
// The path is echoed back resolved, so the message names the directory
// the server actually tried rather than the raw query string.
res.status(400).json({ error: browseErrorMessage(error, resolveBrowsePath(requested)) });
}
}
);

router.get('/lokee/databases', async (req: Request, res: Response) => {
res.json({ databases: await lokeeWeave.listDatabases((req as AuthedRequest).userId!) });
});
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/backend/api/sql-execute.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ConnectionFactory, getAdapter, type ConnectionOptions } from '@foxschema/db';
import { autoAliasSelectColumns } from '@foxschema/sql';
import { isPageableStatement, trimPageProbe, wrapSqlForPage } from './sql-page-wrap';

/**
Expand Down Expand Up @@ -110,8 +111,15 @@ export async function runStatements(
}

const results: StatementResult[] = [];
for (const [index, sql] of statements.entries()) {
for (const [index, original] of statements.entries()) {
const started = Date.now();
// Name any SELECT expression the user left unaliased. The grid keys its
// columns off the row object, and Postgres calls every unaliased
// expression `?column?` while SQL Server leaves them unnamed — so
// `SELECT 1, 2` arrived as a single key and one column silently vanished.
// The rewrite is conservative and returns the statement untouched
// whenever it cannot parse the select list with confidence.
const sql = autoAliasSelectColumns(original).sql;
// Placeholders survive the paging wrap (it only nests the SQL in a
// subquery), so the same positional params apply on either path.
const params = paramsList[index] ?? [];
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/frontend/api/fileApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Fox Schema (foxschema)
* Copyright 2024-2026 Huy Phan <huyplb@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*
* Browse directories on the machine running the backend, for picking a SQLite
* or DuckDB file. Names only — this endpoint never returns file contents.
*/
import { getApiBase, parseJsonResponse } from './apiBase';

export interface FileBrowseEntry {
name: string;
path: string;
kind: 'dir' | 'file';
size?: number;
modifiedAt?: string;
}

export interface FileBrowseResult {
path: string;
parent: string | null;
home: string;
entries: FileBrowseEntry[];
truncated: boolean;
}

export async function browseFiles(path?: string): Promise<FileBrowseResult> {
const query = path ? `?path=${encodeURIComponent(path)}` : '';
const res = await fetch(`${getApiBase()}/files/browse${query}`, { credentials: 'include' });
return parseJsonResponse<FileBrowseResult>(res);
}
Loading
Loading