mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 11:25:06 +03:00
fix: cron sql literal (#45189)
## TL;DR
another parsing issue & updated prev tests to validate this aswell :P
## ex
<table>
<tr>
<td><strong>Before</strong></td>
<td><strong>After</strong></td>
</tr>
<tr>
<td>
<img width="325" height="108" alt="Before"
src="https://github.com/user-attachments/assets/f5583e2e-e0d5-439c-a05a-8120959250ca"
/>
</td>
<td>
<img width="363" height="119" alt="After"
src="https://github.com/user-attachments/assets/7e65a578-acb9-4b90-863c-d3b52411ca7b"
/>
</td>
</tr>
</table>
## ref:
- closes https://github.com/supabase/supabase/issues/45186
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Tests**
* Expanded cron job parsing tests to verify correct handling of
SQL-escaped single quotes in HTTP headers and request bodies.
* **Bug Fixes**
* Fixed cron HTTP request generation and parsing so URLs, headers, and
POST bodies with escaped quotes are preserved and unescaped correctly
when building and reading cron jobs.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
b41abc81d9
commit
344480ef83
2 files changed
+27
-28
No files matched your search
@@ -165,13 +165,13 @@ describe('parseCronJobCommand', () => {
|
||||
})
|
||||
|
||||
it('should return an HTTP request config with POST method, some headers and empty body', () => {
|
||||
const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:=jsonb_build_object('fst', '1', 'snd', '2'), body:='', timeout_milliseconds:=1000 );`
|
||||
const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:=jsonb_build_object('fst', '1', 'snd', 'O''Reilly'), body:='', timeout_milliseconds:=1000 );`
|
||||
expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
|
||||
endpoint: 'https://example.com/api/endpoint',
|
||||
method: 'POST',
|
||||
httpHeaders: [
|
||||
{ name: 'fst', value: '1' },
|
||||
{ name: 'snd', value: '2' },
|
||||
{ name: 'snd', value: "O'Reilly" },
|
||||
],
|
||||
httpBody: '',
|
||||
timeoutMs: 1000,
|
||||
@@ -222,16 +222,13 @@ describe('parseCronJobCommand', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should return an HTTP request config with POST method, plain JSON headers and plain JSON body with ::jsonb typecasting', () => {
|
||||
const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:='{"fst": "1", "snd": "2"}'::jsonb,body:='{"key": "value"}'::jsonb,timeout_milliseconds:=5000);`
|
||||
it('should return an HTTP request config with POST method, plain JSON headers and plain JSON body with escaped SQL strings and ::jsonb typecasting', () => {
|
||||
const command = `select net.http_post( url:='https://example.com/api/endpoint', headers:='{"X-Name":"O''Reilly"}'::jsonb,body:='{"message":"hello there","name":"O''Reilly"}'::jsonb,timeout_milliseconds:=5000);`
|
||||
expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({
|
||||
endpoint: 'https://example.com/api/endpoint',
|
||||
method: 'POST',
|
||||
httpHeaders: [
|
||||
{ name: 'fst', value: '1' },
|
||||
{ name: 'snd', value: '2' },
|
||||
],
|
||||
httpBody: '{"key": "value"}',
|
||||
httpHeaders: [{ name: 'X-Name', value: "O'Reilly" }],
|
||||
httpBody: `{"message":"hello there","name":"O'Reilly"}`,
|
||||
timeoutMs: 5000,
|
||||
type: 'http_request',
|
||||
snippet: command,
|
||||
|
||||
@@ -7,8 +7,11 @@ import { CRON_TABLE_COLUMNS, HTTPHeader, secondsPattern } from './CronJobs.const
|
||||
import { CronJobTableCell } from './CronJobTableCell'
|
||||
import { CronJob } from '@/data/database-cron-jobs/database-cron-jobs-infinite-query'
|
||||
|
||||
const escapeSqlLiteral = (value = '') => value.replaceAll("'", "''")
|
||||
const unescapeSqlLiteral = (value = '') => value.replaceAll("''", "'")
|
||||
|
||||
export function buildCronQuery(name: string, schedule: string, command: string) {
|
||||
const escapedName = name.replace(/'/g, "''")
|
||||
const escapedName = escapeSqlLiteral(name)
|
||||
return `select cron.schedule('${escapedName}', '${schedule}', ${command});`
|
||||
}
|
||||
|
||||
@@ -22,11 +25,13 @@ export const buildHttpRequestCommand = (
|
||||
return `
|
||||
select
|
||||
net.${method === 'GET' ? 'http_get' : 'http_post'}(
|
||||
url:='${url}',
|
||||
url:='${escapeSqlLiteral(url)}',
|
||||
headers:=jsonb_build_object(${headers
|
||||
.filter((v) => v.name && v.value)
|
||||
.map((v) => `'${v.name}', '${v.value}'`)
|
||||
.join(', ')}), ${method === 'POST' && body ? `\n body:='${body}',` : ''}
|
||||
.map((v) => `'${escapeSqlLiteral(v.name)}', '${escapeSqlLiteral(v.value)}'`)
|
||||
.join(
|
||||
', '
|
||||
)}), ${method === 'POST' && body ? `\n body:='${escapeSqlLiteral(body)}',` : ''}
|
||||
timeout_milliseconds:=${timeout}
|
||||
);`
|
||||
}
|
||||
@@ -41,21 +46,17 @@ const DEFAULT_CRONJOB_COMMAND = {
|
||||
} as const
|
||||
|
||||
export const parseCronJobCommand = (originalCommand: string, projectRef: string): CronJobType => {
|
||||
const command = originalCommand
|
||||
.replaceAll('$$', ' ')
|
||||
.replaceAll(/\n/g, ' ')
|
||||
.replaceAll(/\s+/g, ' ')
|
||||
.trim()
|
||||
const command = originalCommand.replaceAll('$$', ' ').replaceAll(/\n/g, ' ').trim()
|
||||
|
||||
if (command.toLocaleLowerCase().startsWith('select net.')) {
|
||||
const methodMatch = command.match(/select net\.([^']+)\(\s*url:=/i)
|
||||
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:='([^']+)'/i)
|
||||
const url = urlMatch?.[1] || ''
|
||||
const urlMatch = command.match(/url:='((?:''|[^'])*)'/i)
|
||||
const url = unescapeSqlLiteral(urlMatch?.[1])
|
||||
|
||||
const bodyMatch = command.match(/body:='(.*?)'/i)
|
||||
const body = bodyMatch?.[1] || ''
|
||||
const bodyMatch = command.match(/body:='((?:''|[^'])*)'/i)
|
||||
const body = unescapeSqlLiteral(bodyMatch?.[1])
|
||||
|
||||
const timeoutMatch = command.match(/timeout_milliseconds:=(\d+)/i)
|
||||
const timeout = timeoutMatch?.[1] || ''
|
||||
@@ -65,8 +66,9 @@ export const parseCronJobCommand = (originalCommand: string, projectRef: string)
|
||||
|
||||
let headersObjs: { name: string; value: string }[] = []
|
||||
if (headersJsonBuildObject) {
|
||||
// convert the header string to array of objects, clean up the values, trim them of spaces and remove the quotation marks at start and end
|
||||
const headers = headersJsonBuildObject.split(',').map((s) => s.trim().replace(/^'|'$/g, ''))
|
||||
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) {
|
||||
@@ -74,8 +76,8 @@ export const parseCronJobCommand = (originalCommand: string, projectRef: string)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const headersStringMatch = command.match(/headers:='([^']*)'/i)
|
||||
const headersString = headersStringMatch?.[1] || '{}'
|
||||
const headersStringMatch = command.match(/headers:='((?:''|[^'])*)'/i)
|
||||
const headersString = unescapeSqlLiteral(headersStringMatch?.[1]) || '{}'
|
||||
try {
|
||||
const parsedHeaders = JSON.parse(headersString)
|
||||
headersObjs = Object.entries(parsedHeaders).map(([name, value]) => ({
|
||||
|
||||
Reference in new issue
Block a user