mirror of
https://github.com/supabase/supabase.git
synced 2026-05-08 09:50:33 -04:00
0433eeb5f5
Mark provenance of SQL via the branded types SafeSqlFragment and UntrustedSqlFragment. Only SafeSqlFragment should be executed; UntrustedSqlFragments require some kind of implicit user approval (show on screen + user has to click something) before they are promoted to SafeSqlFragment. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Editor and RLS tester show loading states for inferred/generated SQL and include a dedicated user SQL editor for safer edits. * **Refactor** * Platform-wide SQL handling tightened: snippets and AI-generated SQL are treated as untrusted/display-only until promoted, improving safety and consistency. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
467 lines
13 KiB
TypeScript
467 lines
13 KiB
TypeScript
import fs from 'fs/promises'
|
|
import path from 'path'
|
|
import { compact, sortBy } from 'lodash'
|
|
import { v4 as uuidv4 } from 'uuid'
|
|
import { z } from 'zod'
|
|
|
|
import { generateDeterministicUuid } from './snippets.browser'
|
|
import { SNIPPETS_DIR } from './snippets.constants'
|
|
|
|
type DeepPartial<T> = T extends object
|
|
? {
|
|
[P in keyof T]?: DeepPartial<T[P]>
|
|
}
|
|
: T
|
|
|
|
export const SnippetSchema = z.object({
|
|
id: z.string().uuid(),
|
|
inserted_at: z.string().default(() => new Date().toISOString()),
|
|
updated_at: z.string().default(() => new Date().toISOString()),
|
|
type: z.literal('sql'),
|
|
name: z.string(),
|
|
description: z.string().optional(),
|
|
favorite: z.boolean().default(false),
|
|
content: z.object({
|
|
sql: z.string(),
|
|
content_id: z.string(),
|
|
schema_version: z.literal('1.0'),
|
|
}),
|
|
visibility: z.union([
|
|
z.literal('user'),
|
|
z.literal('project'),
|
|
z.literal('org'),
|
|
z.literal('public'),
|
|
]),
|
|
project_id: z.number().default(1),
|
|
folder_id: z.string().nullable().default(null),
|
|
owner_id: z.number().default(1),
|
|
owner: z
|
|
.object({
|
|
id: z.number(),
|
|
username: z.string(),
|
|
})
|
|
.default({ id: 1, username: 'johndoe' }),
|
|
updated_by: z
|
|
.object({
|
|
id: z.number(),
|
|
username: z.string(),
|
|
})
|
|
.default({ id: 1, username: 'johndoe' }),
|
|
})
|
|
|
|
export const FolderSchema = z.object({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
owner_id: z.number().default(1),
|
|
parent_id: z.string().nullable(),
|
|
project_id: z.number().default(1),
|
|
})
|
|
|
|
export type Snippet = z.infer<typeof SnippetSchema>
|
|
export type Folder = z.infer<typeof FolderSchema>
|
|
|
|
export type FilesystemEntry = {
|
|
id: string
|
|
name: string
|
|
type: 'file' | 'folder'
|
|
folderId: string | null
|
|
content?: string // Only for files
|
|
createdAt: Date
|
|
}
|
|
|
|
const buildSnippet = (
|
|
filename: string,
|
|
content: string,
|
|
folderId: string | null,
|
|
createdAt: Date
|
|
) => {
|
|
const snippet: Snippet = {
|
|
id: generateDeterministicUuid([folderId, `${filename}.sql`]),
|
|
inserted_at: createdAt.toISOString(),
|
|
updated_at: createdAt.toISOString(),
|
|
type: 'sql',
|
|
name: filename.replace('.sql', ''),
|
|
description: '',
|
|
favorite: false,
|
|
content: {
|
|
sql: content,
|
|
content_id: uuidv4(),
|
|
schema_version: '1.0',
|
|
},
|
|
visibility: 'user',
|
|
project_id: 1,
|
|
folder_id: folderId,
|
|
owner_id: 1,
|
|
owner: { id: 1, username: 'johndoe' },
|
|
updated_by: { id: 1, username: 'johndoe' },
|
|
}
|
|
|
|
return snippet
|
|
}
|
|
|
|
const buildFolder = (name: string) => {
|
|
const folder: Folder = {
|
|
id: generateDeterministicUuid([name]),
|
|
name: name,
|
|
owner_id: 1,
|
|
parent_id: null,
|
|
project_id: 1,
|
|
}
|
|
|
|
return folder
|
|
}
|
|
|
|
const sanitizeName = (name: string): string => {
|
|
// Remove path traversal sequences and normalize
|
|
const sanitized = path.basename(name)
|
|
if (sanitized !== name || name.includes('\0')) {
|
|
throw new Error('Invalid name: path traversal or null bytes detected')
|
|
}
|
|
return sanitized
|
|
}
|
|
|
|
/**
|
|
* Gets a complete snapshot of the filesystem structure including files and folders
|
|
* @returns An array of files and folders with their metadata
|
|
*/
|
|
export async function getFilesystemEntries(): Promise<FilesystemEntry[]> {
|
|
if (SNIPPETS_DIR === '') {
|
|
throw new Error(
|
|
'SNIPPETS_MANAGEMENT_FOLDER env var is not set. Please set it to use snippets properly.'
|
|
)
|
|
}
|
|
|
|
// Ensure the snippets directory exists
|
|
try {
|
|
await fs.access(SNIPPETS_DIR)
|
|
} catch {
|
|
await fs.mkdir(SNIPPETS_DIR, { recursive: true })
|
|
}
|
|
|
|
const entries: FilesystemEntry[] = []
|
|
|
|
const readEntriesRecursively = async (
|
|
dirPath: string,
|
|
folderName: string | null
|
|
): Promise<void> => {
|
|
const items = await fs.readdir(dirPath, { withFileTypes: true })
|
|
const folderId = folderName ? generateDeterministicUuid([folderName]) : null
|
|
|
|
for (const item of items) {
|
|
const itemPath = path.join(dirPath, item.name)
|
|
|
|
if (item.isDirectory()) {
|
|
// if the folder entry is under another folder, skip it. Subdirectories are not supported.
|
|
if (folderName) {
|
|
continue
|
|
}
|
|
|
|
const stats = await fs.stat(itemPath)
|
|
|
|
// Add folder entry
|
|
entries.push({
|
|
id: generateDeterministicUuid([folderId, item.name]),
|
|
name: item.name,
|
|
type: 'folder',
|
|
// Folders are always at root level in this implementation
|
|
folderId: null,
|
|
createdAt: stats.birthtime,
|
|
})
|
|
|
|
await readEntriesRecursively(itemPath, item.name)
|
|
} else if (item.isFile() && item.name.endsWith('.sql')) {
|
|
const [content, stats] = await Promise.all([
|
|
fs.readFile(itemPath, 'utf-8'),
|
|
fs.stat(itemPath),
|
|
])
|
|
const snippetName = item.name.replace('.sql', '')
|
|
|
|
entries.push({
|
|
id: generateDeterministicUuid([folderId, `${snippetName}.sql`]),
|
|
name: snippetName,
|
|
type: 'file',
|
|
folderId: folderId,
|
|
content: content,
|
|
createdAt: stats.birthtime,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
await readEntriesRecursively(SNIPPETS_DIR, null)
|
|
return entries
|
|
}
|
|
|
|
export const getSnippet = async (snippetId: string) => {
|
|
const entries = await getFilesystemEntries()
|
|
const foundSnippet = entries.find((e) => e.type === 'file' && e.id === snippetId)
|
|
|
|
if (!foundSnippet) {
|
|
throw new Error(`Snippet with id ${snippetId} not found`)
|
|
}
|
|
|
|
return buildSnippet(
|
|
foundSnippet.name,
|
|
foundSnippet.content || '',
|
|
foundSnippet.folderId,
|
|
foundSnippet.createdAt
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Gets a filtered paginated list of snippets based on the provided criteria
|
|
*/
|
|
export const getSnippets = async ({
|
|
searchTerm,
|
|
limit,
|
|
cursor,
|
|
sort,
|
|
sortOrder,
|
|
folderId,
|
|
}: {
|
|
searchTerm?: string
|
|
limit?: number
|
|
cursor?: string
|
|
sortOrder?: 'asc' | 'desc'
|
|
sort?: 'name' | 'inserted_at'
|
|
folderId?: string | null
|
|
}): Promise<{ cursor: string | undefined; snippets: Snippet[] }> => {
|
|
// Normalize and set default values
|
|
const normalizedSearchTerm = searchTerm?.trim() ?? ''
|
|
const normalizedLimit = limit ?? 100
|
|
const normalizedSort = sort ?? 'inserted_at'
|
|
const normalizedSortOrder = sortOrder ?? 'desc'
|
|
const normalizedCursor = cursor ?? undefined
|
|
const normalizedFolderId = folderId ?? null
|
|
|
|
// Validate inputs
|
|
if (normalizedLimit <= 0) {
|
|
throw new Error('Limit must be a positive number')
|
|
}
|
|
if (normalizedLimit > 1000) {
|
|
throw new Error('Limit cannot exceed 1000')
|
|
}
|
|
|
|
const entries = await getFilesystemEntries()
|
|
const files = entries.filter(
|
|
(entry): entry is FilesystemEntry & { type: 'file'; content: string } =>
|
|
entry.type === 'file' && entry.content !== undefined && entry.content !== null
|
|
)
|
|
|
|
// Filter snippets based on search term or folder
|
|
let filteredSnippets = files
|
|
if (normalizedSearchTerm) {
|
|
// When searching, look across all folders and support case-insensitive search
|
|
filteredSnippets = files.filter((file) =>
|
|
file.name.toLowerCase().includes(normalizedSearchTerm.toLowerCase())
|
|
)
|
|
} else {
|
|
// Filter by specific folder or root (null)
|
|
filteredSnippets = files.filter((file) => file.folderId === normalizedFolderId)
|
|
}
|
|
|
|
// Sort snippets
|
|
const sortedSnippets = sortBy(filteredSnippets, (snippet) => {
|
|
if (normalizedSort === 'inserted_at') {
|
|
return snippet.createdAt.getTime()
|
|
}
|
|
return snippet.name.toLowerCase() // Case-insensitive name sorting
|
|
})
|
|
|
|
if (normalizedSortOrder === 'desc') {
|
|
sortedSnippets.reverse()
|
|
}
|
|
|
|
// Apply cursor-based pagination
|
|
let paginatedSnippets = sortedSnippets
|
|
if (normalizedCursor) {
|
|
const cursorIndex = sortedSnippets.findIndex((s) => s.id === normalizedCursor)
|
|
if (cursorIndex !== -1) {
|
|
paginatedSnippets = sortedSnippets.slice(cursorIndex + 1)
|
|
}
|
|
// If cursor not found, return all snippets (graceful degradation)
|
|
}
|
|
|
|
// Apply limit and determine next cursor
|
|
let nextCursor: string | undefined = undefined
|
|
let finalSnippets = paginatedSnippets
|
|
|
|
if (normalizedLimit && paginatedSnippets.length > normalizedLimit) {
|
|
finalSnippets = paginatedSnippets.slice(0, normalizedLimit)
|
|
nextCursor = finalSnippets[finalSnippets.length - 1].id
|
|
}
|
|
|
|
return {
|
|
cursor: nextCursor,
|
|
snippets: finalSnippets.map((file) =>
|
|
buildSnippet(file.name, file.content, file.folderId, file.createdAt)
|
|
),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Saves a snippet to the filesystem
|
|
*/
|
|
export async function saveSnippet(snippet: Snippet): Promise<Snippet> {
|
|
const entries = await getFilesystemEntries()
|
|
const existingSnippet = entries.find((entry) => entry.id === snippet.id && entry.type === 'file')
|
|
|
|
if (existingSnippet) {
|
|
throw new Error(`Snippet with id ${snippet.id} already exists`)
|
|
}
|
|
|
|
// check if the folder exists
|
|
if (snippet.folder_id !== null) {
|
|
const existingFolder = entries.find(
|
|
(entry) => entry.id === snippet.folder_id && entry.type === 'folder'
|
|
)
|
|
if (existingFolder === undefined) {
|
|
throw new Error(`Folder with id ${snippet.folder_id} not found`)
|
|
}
|
|
}
|
|
|
|
const snippetName = sanitizeName(snippet.name)
|
|
const content = snippet.content.sql || ''
|
|
const folderId = snippet.folder_id || null
|
|
const folder = entries.find((f) => f.id === folderId && f.type === 'folder')
|
|
|
|
const folderPath = folder ? path.join(SNIPPETS_DIR, folder.name) : SNIPPETS_DIR
|
|
const filePath = path.join(folderPath, `${snippetName}.sql`)
|
|
await fs.writeFile(filePath, content || '', 'utf-8')
|
|
const stats = await fs.stat(filePath)
|
|
|
|
const result = buildSnippet(snippetName, content, snippet.folder_id, stats.birthtime)
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Deletes a snippet from the filesystem
|
|
*/
|
|
export async function deleteSnippet(id: string): Promise<void> {
|
|
const entries = await getFilesystemEntries()
|
|
const found = entries.find((entry) => entry.id === id && entry.type === 'file')
|
|
|
|
if (!found) {
|
|
throw new Error(`Snippet with id ${id} not found`)
|
|
}
|
|
|
|
const filename = `${found.name}.sql`
|
|
const currentFolder = entries.find((f) => f.id === found.folderId && f.type === 'folder')
|
|
const paths = compact([SNIPPETS_DIR, currentFolder?.name, filename])
|
|
const filePath = path.join(...paths)
|
|
|
|
try {
|
|
await fs.unlink(filePath)
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates a snippet in the filesystem. It also handles renaming and moving.
|
|
*/
|
|
export async function updateSnippet(id: string, updates: DeepPartial<Snippet>): Promise<Snippet> {
|
|
const entries = await getFilesystemEntries()
|
|
const foundSnippet = entries
|
|
.filter(
|
|
(entry): entry is FilesystemEntry & { type: 'file'; content: string } => entry.type === 'file'
|
|
)
|
|
.find((s) => s.id === id)
|
|
|
|
if (!foundSnippet) {
|
|
throw new Error(`Snippet with id ${id} not found`)
|
|
}
|
|
|
|
const newId = generateDeterministicUuid([
|
|
updates.folder_id !== undefined ? updates.folder_id : foundSnippet.folderId,
|
|
`${updates.name ?? foundSnippet.name}.sql`,
|
|
])
|
|
|
|
const snippetAtTargetLocation = entries.find(
|
|
(entry) => entry.id === newId && entry.type === 'file'
|
|
)
|
|
|
|
if (snippetAtTargetLocation && snippetAtTargetLocation.id !== foundSnippet.id) {
|
|
throw new Error(
|
|
`Snippet named "${updates.name ?? foundSnippet.name}" already exists in the specified folder`
|
|
)
|
|
}
|
|
|
|
const snippet = buildSnippet(
|
|
foundSnippet.name,
|
|
foundSnippet.content || '',
|
|
foundSnippet.folderId,
|
|
foundSnippet.createdAt
|
|
)
|
|
|
|
// it's easier to delete the old file first and then recreate a new one
|
|
await deleteSnippet(snippet.id)
|
|
|
|
const updatedSnippet = await saveSnippet({
|
|
name: updates.name ?? snippet.name,
|
|
content: updates.content ?? snippet.content,
|
|
// folder_id can be null
|
|
folder_id: updates.folder_id !== undefined ? updates.folder_id : snippet.folder_id,
|
|
} as Snippet)
|
|
|
|
return updatedSnippet
|
|
}
|
|
|
|
export const getFolders = async (folderId: string | null = null): Promise<Folder[]> => {
|
|
const entries = await getFilesystemEntries()
|
|
const folders = entries
|
|
.filter(
|
|
(entry): entry is FilesystemEntry & { type: 'folder' } =>
|
|
entry.type === 'folder' && entry.folderId === folderId
|
|
)
|
|
.map((folder) => buildFolder(folder.name))
|
|
return folders
|
|
}
|
|
|
|
/**
|
|
* Creates a new folder as an actual directory
|
|
*/
|
|
export async function createFolder(_folderName: string): Promise<Folder> {
|
|
const folderName = sanitizeName(_folderName)
|
|
|
|
const entries = await getFilesystemEntries()
|
|
const existingFolder = entries.find((folder) => folder.name === folderName)
|
|
|
|
if (existingFolder) {
|
|
throw new Error(`Folder with name ${folderName} already exists`)
|
|
}
|
|
|
|
const folderPath = path.join(SNIPPETS_DIR, folderName)
|
|
|
|
await fs.mkdir(folderPath, { recursive: true })
|
|
const newFolder = buildFolder(folderName)
|
|
|
|
return newFolder
|
|
}
|
|
|
|
/**
|
|
* Deletes a folder directory from the filesystem
|
|
* @throws {Error} If the folder doesn't exist
|
|
*/
|
|
export async function deleteFolder(id: string): Promise<void> {
|
|
const entries = await getFilesystemEntries()
|
|
const folder = entries.find((f) => f.id === id && f.type === 'folder')
|
|
|
|
if (!folder) {
|
|
throw new Error(`Folder with id ${id} not found`)
|
|
}
|
|
|
|
const folderPath = path.join(SNIPPETS_DIR, folder.name)
|
|
try {
|
|
await fs.rm(folderPath, { recursive: true, force: true })
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
throw error
|
|
}
|
|
// If folder doesn't exist, still throw the original error
|
|
throw new Error(`Folder with id ${id} not found`)
|
|
}
|
|
}
|