Skip to content
Open
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
14 changes: 14 additions & 0 deletions api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
import { decodeSession, decryptMessage, encryptMessage } from '/api/user.ts'
import {
fetchTablesData,
insertTableData,
runSQL,
SQLQueryError,
updateTableData,
Expand Down Expand Up @@ -527,6 +528,19 @@ const defs = {
rows: ARR(OBJ({}, 'A row of the result set'), 'The result set rows'),
}),
}),
'POST/api/deployment/table/insert': route({
authorize: withUserSession,
fn: (ctx, { deployment, table, data }) => {
const dep = withDeploymentTableAccess(ctx, deployment)
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

The insert route does not validate that table exists in the cached schema (or that data only contains known columns) like POST /api/deployment/table/data does. This allows arbitrary table/column names to reach SQL construction; fetch the cached schema for the deployment and reject unknown table/columns before calling insertTableData.

Suggested change
const dep = withDeploymentTableAccess(ctx, deployment)
const dep = withDeploymentTableAccess(ctx, 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' })
}
const allowedColumns = new Set(tableDef.columns.map((c) => c.name))
for (const columnName of Object.keys(data)) {
if (!allowedColumns.has(columnName)) {
throw respond.BadRequest({
message: `Unknown column "${columnName}" for table "${table}"`,
})
}
}

Copilot uses AI. Check for mistakes.
return insertTableData(dep, table, data)
},
input: OBJ({
deployment: STR("The deployment's URL"),
table: STR('The table name'),
data: OBJ({}, 'The row data to insert'),
}),
output: OBJ({}, 'The result of the insert'),
}),
'POST/api/deployment/table/update': route({
authorize: withUserSession,
fn: (ctx, { deployment, table, pk, data }) => {
Expand Down
42 changes: 40 additions & 2 deletions api/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,44 @@ export const fetchTablesData = async (
}
}

export const insertTableData = async (
deployment: Deployment,
table: string,
data: Record<string, unknown>,
) => {
const { sqlEndpoint, sqlToken } = deployment
if (!sqlToken || !sqlEndpoint) {
throw Error('Missing SQL endpoint or token')
}
const projectFunctions = getProjectFunctions(deployment.projectId)
const transformedData = await applyWriteTransformers(
data,
deployment.projectId,
deployment.url,
table,
projectFunctions,
)
const columns = Object.keys(transformedData)
const values = Object.values(transformedData).map((v) => {
if (v === null) return 'NULL'
if (typeof v === 'string') return `'${v.replaceAll("'", "''")}'`
return String(v)
})
Comment on lines +289 to +293
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

insertTableData serializes non-string values using String(v). This breaks for objects/arrays (becoming "[object Object]") and for Uint8Array/BLOB values (becoming comma-joined bytes), which will corrupt data for projects using write transformers (e.g., compressed blobs). Use SQL parameters (runSQL's params) and pass values as-is, or explicitly encode complex/binary types in a dialect-safe way.

Copilot uses AI. Check for mistakes.
const query = `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${
values.join(', ')
})`
Comment on lines +294 to +296
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

The INSERT query interpolates the table name and column identifiers directly into SQL without validating/quoting them. Even with auth, this is still SQL injection-prone and can break on reserved words; validate identifiers against the cached schema (or strictly whitelist) and/or quote identifiers appropriately for the detected dialect.

Copilot uses AI. Check for mistakes.
const rows = await runSQL(sqlEndpoint, sqlToken, query)

// Apply read transformer pipeline
return await applyReadTransformers(
rows,
deployment.projectId,
deployment.url,
table,
projectFunctions,
)
}

export const updateTableData = async (
deployment: Deployment,
table: string,
Expand All @@ -293,13 +331,13 @@ export const updateTableData = async (
const val = v === null
? 'NULL'
: typeof v === 'string'
? `'${v.replace(/'/g, "''")}'`
? `'${v.replaceAll("'", "''")}'`
: String(v)
return `${k} = ${val}`
})

const pkVal = typeof pk.value === 'string'
? `'${String(pk.value).replace(/'/g, "''")}'`
? `'${String(pk.value).replaceAll("'", "''")}'`
: String(pk.value)

const query = `UPDATE ${table} SET ${
Expand Down
117 changes: 116 additions & 1 deletion web/pages/DeploymentPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1717,10 +1717,125 @@ const LogDetails = () => {
)
}

const InsertRow = () => {
const tableName = url.params.table || schema.data?.tables?.[0]?.table
const tableDef = schema.data?.tables?.find((t) => t.table === tableName)

if (!tableName || !tableDef) {
return (
<div class='p-4 text-base-content/60'>
Select a table from the schema panel first.
</div>
)
}

const onInsert = async (e: Event) => {
e.preventDefault()
const form = e.currentTarget as HTMLFormElement
const formData = new FormData(form)
const data: Record<string, unknown> = {}

for (const [key, val] of formData.entries()) {
const col = tableDef.columns.find((c) => c.name === key)
if (!col) continue
const type = col.type
if (
type.includes('Int') || type.includes('Float') ||
type.includes('Decimal')
) {
data[key] = Number(val)
} else if (type.includes('Bool')) {
data[key] = val === 'on'
Comment on lines +1738 to +1748
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

Insert uses the same checkbox/FormData pattern as update: unchecked boolean inputs won't appear in FormData, so you can't explicitly insert false (the key will be missing). Read checkbox states via form.elements or ensure a default value is submitted for unchecked checkboxes.

Suggested change
for (const [key, val] of formData.entries()) {
const col = tableDef.columns.find((c) => c.name === key)
if (!col) continue
const type = col.type
if (
type.includes('Int') || type.includes('Float') ||
type.includes('Decimal')
) {
data[key] = Number(val)
} else if (type.includes('Bool')) {
data[key] = val === 'on'
// Ensure boolean checkbox fields are always present in `data` with an explicit
// true/false value, regardless of whether they are checked (and thus present
// in FormData) or not.
for (const col of tableDef.columns) {
if (!col.type.includes('Bool')) continue
const elem = form.elements.namedItem(col.name)
if (elem instanceof HTMLInputElement && elem.type === 'checkbox') {
data[col.name] = elem.checked
}
}
for (const [key, val] of formData.entries()) {
const col = tableDef.columns.find((c) => c.name === key)
if (!col) continue
const type = col.type
// If this is a boolean field rendered as a checkbox, we've already set it
// from `elem.checked` above, and the FormData value (`"on"`) is redundant.
const elem = form.elements.namedItem(key)
if (
type.includes('Bool') &&
elem instanceof HTMLInputElement &&
elem.type === 'checkbox'
) {
continue
}
if (
type.includes('Int') || type.includes('Float') ||
type.includes('Decimal')
) {
data[key] = Number(val)

Copilot uses AI. Check for mistakes.
} else if (
type.includes('JSON') || type.includes('Array') || type.includes('Map')
) {
try {
data[key] = JSON.parse(val as string)
} catch {
data[key] = val
}
} else {
data[key] = val
}
}

try {
await api['POST/api/deployment/table/insert'].fetch({
deployment: url.params.dep!,
table: tableName,
data,
})
toast('Row inserted successfully')
tableData.fetch()
navigate({ params: { drawer: null } })
} catch (err) {
toast(err instanceof Error ? err.message : String(err), 'error')
}
}

return (
<div class='flex flex-col h-full bg-base-100'>
<div class='p-4 border-b border-base-300 flex items-center justify-between sticky top-0 bg-base-100 z-10'>
<h3 class='font-semibold text-lg'>Insert Row: {tableName}</h3>
<A
params={{ drawer: null }}
replace
class='btn btn-ghost btn-sm btn-circle'
>
<XCircle class='h-5 w-5' />
</A>
</div>
<form onSubmit={onInsert} class='flex-1 flex flex-col min-h-0'>
<div class='flex-1 overflow-y-auto p-4 space-y-4'>
{tableDef.columns.map((col) => {
const type = col.type
const key = col.name
const isObject = type.includes('Map') || type.includes('Array') ||
type.includes('Tuple') || type.includes('Nested') ||
type.includes('JSON') || type.toLowerCase().includes('blob')
const isNumber = type.includes('Int') || type.includes('Float') ||
type.includes('Decimal')
const isBoolean = type.includes('Bool')
const isDate = type.includes('Date') || type.includes('Time')

return (
<div key={key} class='form-control'>
<label class='label py-1'>
<span class='label-text text-xs font-semibold text-base-content/50 uppercase tracking-wider'>
{key}
</span>
<span class='label-text-alt text-[10px] opacity-50'>
{type}
</span>
</label>
{isObject
? <ObjectInput name={key} />
: isBoolean
? <BooleanInput name={key} />
: isDate
? <DateInput name={key} />
: isNumber
? <NumberInput name={key} />
: <TextInput name={key} />}
</div>
)
})}
</div>
<div class='p-4 border-t border-base-300 sticky bottom-0 bg-base-100'>
<button type='submit' class='btn btn-primary w-full'>
<Plus class='h-4 w-4' />
Insert Row
</button>
</div>
</form>
</div>
)
}

type DrawerTab = 'history' | 'insert' | 'view-row' | 'view-log'
const drawerViews: Record<DrawerTab, JSX.Element> = {
history: <QueryHistory />,
insert: <div></div>,
insert: <InsertRow />,
'view-row': <RowDetails />,
'view-log': <LogDetails />,
} as const
Expand Down
Loading