Skip to content
52 changes: 52 additions & 0 deletions api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from './click-house-client.ts'
import { decryptMessage, encryptMessage } from './user.ts'
import { log } from './lib/log.ts'
import { type ColumnInfo, fetchTablesData } from './sql.ts'

const withUserSession = ({ user }: RequestContext) => {
if (!user) throw Error('Missing user session')
Expand Down Expand Up @@ -417,6 +418,57 @@ const defs = {
output: ARR(LogSchema, 'List of logs'),
description: 'Get logs from ClickHouse',
}),
'POST/api/deployment/table/data': route({
fn: (_, { deployment, table, ...input }) => {
const dep = DeploymentsCollection.get(deployment)
if (!dep) {
throw respond.NotFound({ message: 'Deployment not found' })
}

if (!dep?.databaseEnabled) {
throw respond.BadRequest({
message: 'Database not enabled for deployment',
})
}

const schema = DatabaseSchemasCollection.get(deployment)
if (!schema) throw respond.NotFound({ message: 'Schema not cached yet' })
const tableDef = schema.tables.find((t) => t.table === table)
if (!tableDef) {
throw respond.NotFound({ message: 'Table not found in schema' })
}

return fetchTablesData(
{ ...input, deployment: dep, table },
tableDef.columnsMap as unknown as Map<string, ColumnInfo>,
)
},
input: OBJ({
deployment: STR("The deployment's URL"),
table: STR('The table name'),
filter: ARR(
OBJ({
key: STR('The column to filter by'),
comparator: LIST(
['=', '!=', '<', '<=', '>', '>=', 'LIKE', 'ILIKE'],
'The comparison operator',
),
value: STR('The value to filter by'),
}),
'The filtering criteria',
),
sort: ARR(
OBJ({
key: STR('The column to sort by'),
order: LIST(['ASC', 'DESC'], 'The sort order (ASC or DESC)'),
}),
'The sorting criteria',
),
limit: STR('The maximum number of rows to return'),
offset: STR('The number of rows to skip'),
Comment on lines +467 to +468
Copy link

Copilot AI Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The limit and offset parameters should be defined as numbers (NUM) rather than strings (STR) since they represent numeric values and are parsed as integers in the implementation.

Suggested change
limit: STR('The maximum number of rows to return'),
offset: STR('The number of rows to skip'),
limit: NUM('The maximum number of rows to return'),
offset: NUM('The number of rows to skip'),

Copilot uses AI. Check for mistakes.
search: STR('The search term to filter by'),
}),
}),
} as const

export type RouteDefinitions = typeof defs
Expand Down
1 change: 1 addition & 0 deletions api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const DatabaseSchemaDef = OBJ({
type: STR(),
ordinal: NUM(),
})),
columnsMap: optional(OBJ({})),
schema: optional(STR()),
table: STR(),
})),
Expand Down
100 changes: 97 additions & 3 deletions api/sql.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { DatabaseSchemasCollection, DeploymentsCollection } from './schema.ts'
import {
DatabaseSchemasCollection,
Deployment,
DeploymentsCollection,
} from './schema.ts'
import { DB_SCHEMA_REFRESH_MS } from './lib/env.ts'
import { log } from './lib/log.ts'

Expand Down Expand Up @@ -59,11 +63,12 @@ async function fetchSchema(endpoint: string, token: string, dialect: string) {
return await runSQL(endpoint, token, sql)
}

type ColumnInfo = { name: string; type: string; ordinal: number }
export type ColumnInfo = { name: string; type: string; ordinal: number }
type TableInfo = {
schema: string | undefined
table: string
columns: ColumnInfo[]
columnsMap: Map<string, ColumnInfo>
}

export async function refreshOneSchema(
Expand All @@ -80,7 +85,9 @@ export async function refreshOneSchema(
const table = r.table_name as string
if (!table) continue
const key = (schema ? schema + '.' : '') + table
if (!tableMap.has(key)) tableMap.set(key, { schema, table, columns: [] })
if (!tableMap.has(key)) {
tableMap.set(key, { schema, table, columns: [], columnsMap: new Map() })
}
tableMap.get(key)!.columns.push({
name: String(r.column_name),
type: String(r.data_type || ''),
Expand All @@ -90,6 +97,10 @@ export async function refreshOneSchema(
const tables = [...tableMap.values()].map((t) => ({
...t,
columns: t.columns.sort((a, b) => a.ordinal - b.ordinal),
columnsMap: t.columns.reduce((map, col) => {
map.set(col.name, col)
return map
}, new Map<string, ColumnInfo>()),
}))
const payload = {
deploymentUrl: dep.url,
Expand Down Expand Up @@ -129,3 +140,86 @@ export function startSchemaRefreshLoop() {
}, DB_SCHEMA_REFRESH_MS) as unknown as number
log.info('schema-refresh-loop-started', { everyMs: DB_SCHEMA_REFRESH_MS })
}

type FetchTablesParams = {
deployment: Deployment
table: string
filter: { key: string; comparator: string; value: string }[]
sort: { key: string; order: 'ASC' | 'DESC' }[]
limit: string
offset: string
search: string
}

const constructWhereClause = (
params: FetchTablesParams,
columnsMap: Map<string, ColumnInfo>,
) => {
const whereClauses: string[] = []
if (params.filter.length) {
for (const filter of params.filter) {
const { key, comparator, value } = filter
const column = columnsMap.get(key)
if (!column) {
throw Error(`Invalid filter column: ${key}`)
}
const safeValue = value.replace(/'/g, "''")
whereClauses.push(`${key} ${comparator} '${safeValue}'`)
Comment on lines +166 to +167
Copy link

Copilot AI Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This basic string escaping approach is insufficient for SQL injection protection. Consider using parameterized queries or a proper SQL escaping library instead of manual quote replacement.

Copilot uses AI. Check for mistakes.
}
}
if (params.search) {
const searchClauses = columnsMap.values().map((col) => {
return `${col.name} LIKE '%${params.search.replace(/'/g, "''")}%'`
Copy link

Copilot AI Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The search functionality uses the same vulnerable string escaping approach. This could allow SQL injection through the search parameter.

Copilot uses AI. Check for mistakes.
}).toArray()
if (searchClauses.length) {
whereClauses.push(`(${searchClauses.join(' OR ')})`)
}
}
return whereClauses.length ? 'WHERE ' + whereClauses.join(' AND ') : ''
}

const constructOrderByClause = (
params: FetchTablesParams,
columnsMap: Map<string, ColumnInfo>,
) => {
if (!params.sort.length) return ''
const orderClauses: string[] = []
for (const sort of params.sort) {
const { key, order } = sort
const column = columnsMap.get(key)
if (!column) {
throw Error(`Invalid sort column: ${key}`)
}
orderClauses.push(`${key} ${order}`)
}
return orderClauses.length ? 'ORDER BY ' + orderClauses.join(', ') : ''
}

export const fetchTablesData = (
params: FetchTablesParams,
columnsMap: Map<string, ColumnInfo>,
) => {
const { sqlEndpoint, sqlToken } = params.deployment
if (!sqlToken || !sqlEndpoint) {
throw Error('Missing SQL endpoint or token')
}
const whereClause = constructWhereClause(params, columnsMap)
const orderByClause = constructOrderByClause(params, columnsMap)

let limitOffsetClause = ''
const limit = Math.floor(Number(params.limit))

if (params.limit && limit > 0) {
limitOffsetClause += `LIMIT ${limit}`

const offset = Math.floor(Number(params.offset))
if (params.offset && offset >= 0) {
limitOffsetClause += ` OFFSET ${offset}`
}
}

const query =
`SELECT * FROM ${params.table} ${whereClause} ${orderByClause} ${limitOffsetClause}`

return runSQL(sqlEndpoint, sqlToken, query)
}
4 changes: 2 additions & 2 deletions web/components/Filtre.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ type FilterRow = { idx: number; key: string; op: string; value: string }

const filterOperators = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'like'] as const

function parseFilters(prefix: string): FilterRow[] {
export function parseFilters(prefix: string): FilterRow[] {
const data = url.getAll(`f${prefix}`)
const rows: FilterRow[] = []
for (let i = 0; i < data.length; i++) {
Expand Down Expand Up @@ -61,7 +61,7 @@ function removeFilter(prefix: string, idx: number) {
// --- Sort (URL) -------------------------------------------------------------
type SortRow = { idx: number; key: string; dir: 'asc' | 'desc' }

function parseSort(prefix: string): SortRow[] {
export function parseSort(prefix: string): SortRow[] {
const data = url.getAll(`s${prefix}`)
const rows: SortRow[] = []
for (let i = 0; i < data.length; i++) {
Expand Down
Loading