Files
Vaibhav fe769bfe7e fix: escape quotes (#45848)
## TL;DR


- closes https://github.com/supabase/supabase/issues/45860
- closes https://github.com/supabase/supabase/issues/45544
 & supersedes https://github.com/supabase/supabase/pull/45543


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added explicit allowed facet fields and validation to prevent invalid
facet usage.

* **Bug Fixes**
* Improved filter handling by coercing values to strings and properly
escaping single quotes for array, scalar, and LIKE filters (including
facet search), reducing query errors and injection risks.
* Enhanced parsing of scheduled-job SQL commands to tolerate varied
casing/whitespace and strip leading SELECT before processing.

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45848)
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-13 08:32:15 -06:00

272 lines
8.2 KiB
TypeScript

import { keyword, literal, safeSql, type SafeSqlFragment } from '@supabase/pg-meta/src/pg-format'
import { toString as CronToString } from 'cronstrue'
import { Column } from 'react-data-grid'
import { cn } from 'ui'
import { CronJobType } from './CreateCronJobSheet/CreateCronJobSheet.constants'
import { CRON_TABLE_COLUMNS, HTTPHeader, secondsPattern } from './CronJobs.constants'
import { CronJobTableCell } from './CronJobTableCell'
import { CronJob } from '@/data/database-cron-jobs/database-cron-jobs-infinite-query'
const unescapeSqlLiteral = (value = '', isEscapeString = false) => {
const unescaped = value.replaceAll("''", "'")
return isEscapeString ? unescaped.replaceAll('\\\\', '\\') : unescaped
}
export function buildCronQuery(name: string, schedule: string, command: string): SafeSqlFragment {
return safeSql`select cron.schedule(${literal(name)}, ${literal(schedule)}, ${literal(command)});`
}
export const buildHttpRequestCommand = (
method: 'GET' | 'POST',
url: string,
headers: HTTPHeader[] = [],
body: string | undefined,
timeout: number
): SafeSqlFragment => {
const funcName = keyword(method === 'GET' ? 'http_get' : 'http_post')
const headersJson = JSON.stringify(
Object.fromEntries(headers.filter((v) => v.name && v.value).map((v) => [v.name, v.value]))
)
const bodyPart = method === 'POST' && body ? safeSql`\n body:=${literal(body)},` : safeSql``
return safeSql`
select
net.${funcName}(
url:=${literal(url)},
headers:=${literal(headersJson)}::jsonb, ${bodyPart}
timeout_milliseconds:=${literal(timeout)}
);`
}
const DEFAULT_CRONJOB_COMMAND = {
type: 'sql_snippet',
snippet: '',
// add default values for the other command types. Even though they don't exist in sql_snippet, they'll still work as default values.
method: 'POST',
timeoutMs: 1000,
httpBody: '',
} as const
export const parseCronJobCommand = (originalCommand: string, projectRef: string): CronJobType => {
const command = originalCommand.replaceAll('$$', ' ').replaceAll(/\n/g, ' ').trim()
if (command.toLocaleLowerCase().match(/^select\s+net\./)) {
const methodMatch = command.match(/select\s+net\.([^']+)\(\s*url:=/i)
const method = methodMatch?.[1] || ''
const urlMatch = command.match(/url:=(E)?'((?:''|[^'])*)'/i)
const url = unescapeSqlLiteral(urlMatch?.[2], Boolean(urlMatch?.[1]))
const bodyMatch = command.match(/body:=(E)?'((?:''|[^'])*)'/i)
const body = unescapeSqlLiteral(bodyMatch?.[2], Boolean(bodyMatch?.[1]))
const timeoutMatch = command.match(/timeout_milliseconds:=(\d+)/i)
const timeout = timeoutMatch?.[1] || ''
const headersJsonBuildObjectMatch = command.match(/headers:=jsonb_build_object\(([^)]*)/i)
const headersJsonBuildObject = headersJsonBuildObjectMatch?.[1] || ''
let headersObjs: { name: string; value: string }[] = []
if (headersJsonBuildObject) {
const headers = headersJsonBuildObject
.split(',')
.map((s) => unescapeSqlLiteral(s.trim().replace(/^'|'$/g, '')))
for (let i = 0; i < headers.length; i += 2) {
if (headers[i] && headers[i].length > 0) {
headersObjs.push({ name: headers[i], value: headers[i + 1] })
}
}
} else {
const headersStringMatch = command.match(/headers:=(E)?'((?:''|[^'])*)'/i)
const headersString =
unescapeSqlLiteral(headersStringMatch?.[2], Boolean(headersStringMatch?.[1])) || '{}'
try {
const parsedHeaders = JSON.parse(headersString)
headersObjs = Object.entries(parsedHeaders).map(([name, value]) => ({
name,
value: value as string,
}))
} catch (error) {
console.error('Error parsing headers:', error)
}
}
// If there's a search param or hash in the edge function URL, let it be handled by the HTTP Request case.
// Otherwise, the params/hash may be lost during editing of the cron job.
let searchParams = ''
let urlHash = ''
try {
const urlObject = new URL(url)
searchParams = urlObject.search
urlHash = urlObject.hash
} catch {}
if (
url.includes(`${projectRef}.supabase.`) &&
url.includes('/functions/v1/') &&
searchParams.length === 0 &&
urlHash.length === 0
) {
return {
type: 'edge_function',
method: method === 'http_get' ? 'GET' : 'POST',
edgeFunctionName: url,
httpHeaders: headersObjs,
httpBody: body,
timeoutMs: Number(timeout ?? 1000),
snippet: originalCommand,
}
}
if (url !== '') {
return {
type: 'http_request',
method: method === 'http_get' ? 'GET' : 'POST',
endpoint: url,
httpHeaders: headersObjs,
httpBody: body,
timeoutMs: Number(timeout ?? 1000),
snippet: originalCommand,
}
}
}
const regexDBFunction = /select\s+[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+\s*\(\)/g
if (command.toLocaleLowerCase().match(regexDBFunction)) {
const [schemaName, functionName] = command
.replace(/^select\s+/i, '')
.replace(/\(.*\);*/, '')
.trim()
.split('.')
return {
type: 'sql_function',
schema: schemaName,
functionName: functionName,
snippet: originalCommand,
}
}
if (command.length > 0) {
return {
type: 'sql_snippet',
snippet: originalCommand,
}
}
return DEFAULT_CRONJOB_COMMAND
}
export function calculateDuration(start: string, end: string): string {
const startTime = new Date(start).getTime()
const endTime = new Date(end).getTime()
const duration = endTime - startTime
if (isNaN(duration)) return 'Invalid Date'
if (duration < 1000) return `${duration}ms`
if (duration < 60000) return `${(duration / 1000).toFixed(1)}s`
return `${(duration / 60000).toFixed(1)}m`
}
export function formatDate(dateString: string): string {
const date = new Date(dateString)
if (isNaN(date.getTime())) {
return 'Invalid Date'
}
const options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short', // Use 'long' for full month name
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false, // Use 12-hour format if preferred
timeZoneName: 'short', // Optional: to include timezone
}
return date.toLocaleString(undefined, options)
}
export function isSecondsFormat(schedule: string): boolean {
return secondsPattern.test(schedule.trim().toLocaleLowerCase())
}
export function getScheduleMessage(scheduleString: string) {
if (!scheduleString) {
return 'Enter a valid cron expression above'
}
// if the schedule is in seconds format, scheduleString is same as the schedule
if (secondsPattern.test(scheduleString)) {
return `The cron will run every ${scheduleString}`
}
if (scheduleString.includes('Invalid cron expression')) {
return scheduleString
}
const readableSchedule = scheduleString
.split(' ')
.map((s, i) => (i === 0 ? s.toLowerCase() : s))
.join(' ')
return `The cron will run ${readableSchedule}.`
}
export const formatScheduleString = (value: string) => {
try {
if (secondsPattern.test(value)) {
return value
} else {
return CronToString(value)
}
} catch (error) {
return ''
}
}
export const formatCronJobColumns = ({
onSelectEdit,
onSelectDelete,
}: {
onSelectEdit: (job: CronJob) => void
onSelectDelete: (job: CronJob) => void
}): Array<Column<CronJob>> => {
return CRON_TABLE_COLUMNS.map((col) => {
const res: Column<CronJob> = {
key: col.id,
name: col.name,
minWidth: col.minWidth ?? 100,
maxWidth: col.maxWidth,
width: col.width,
resizable: col.resizable ?? false,
sortable: false,
draggable: false,
headerCellClass: undefined,
renderHeaderCell: () => {
return (
<div
className={cn(
'flex items-center justify-between font-normal text-xs w-full',
col.id === 'jobname' && 'ml-8'
)}
>
<p className="text-foreground!">{col.name}</p>
</div>
)
},
renderCell: ({ row }) => (
<CronJobTableCell
row={row}
col={col}
onSelectEdit={onSelectEdit}
onSelectDelete={onSelectDelete}
/>
),
}
return res
})
}