Security & Data Integrity: - Centralized transaction helper with deadlock retry (exponential backoff) - SafeQueryBuilder for safe parameterized queries - Zod-based input validation middleware - Audit logging to Outline's events table Performance: - Cursor-based pagination for large datasets - Pool monitoring with configurable alerts - Database index migrations for optimal query performance Changes: - Refactored bulk-operations, desk-sync, export-import to use centralized transaction helper - Added 7 new utility modules (audit, monitoring, pagination, query-builder, transaction, validation) - Created migrations/001_indexes.sql with 40+ recommended indexes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
278 lines
6.2 KiB
TypeScript
278 lines
6.2 KiB
TypeScript
/**
|
|
* MCP Outline PostgreSQL - Safe Query Builder
|
|
* Helper for parameterized queries to prevent SQL injection
|
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
|
*/
|
|
|
|
import { sanitizeInput, isValidUUID } from './security.js';
|
|
|
|
/**
|
|
* Safe query builder with automatic parameter handling
|
|
*/
|
|
export class SafeQueryBuilder {
|
|
private params: any[] = [];
|
|
private paramIndex = 1;
|
|
private conditions: string[] = [];
|
|
|
|
/**
|
|
* Add a parameter and return its placeholder
|
|
*/
|
|
addParam(value: any): string {
|
|
this.params.push(value);
|
|
return `$${this.paramIndex++}`;
|
|
}
|
|
|
|
/**
|
|
* Get current parameter index (useful for manual queries)
|
|
*/
|
|
getNextIndex(): number {
|
|
return this.paramIndex;
|
|
}
|
|
|
|
/**
|
|
* Build ILIKE condition (case-insensitive search)
|
|
*/
|
|
buildILike(column: string, value: string): string {
|
|
const sanitized = sanitizeInput(value);
|
|
return `${column} ILIKE ${this.addParam(`%${sanitized}%`)}`;
|
|
}
|
|
|
|
/**
|
|
* Build exact ILIKE condition (no wildcards)
|
|
*/
|
|
buildILikeExact(column: string, value: string): string {
|
|
const sanitized = sanitizeInput(value);
|
|
return `${column} ILIKE ${this.addParam(sanitized)}`;
|
|
}
|
|
|
|
/**
|
|
* Build ILIKE condition with prefix match
|
|
*/
|
|
buildILikePrefix(column: string, value: string): string {
|
|
const sanitized = sanitizeInput(value);
|
|
return `${column} ILIKE ${this.addParam(`${sanitized}%`)}`;
|
|
}
|
|
|
|
/**
|
|
* Build IN clause for array of values
|
|
*/
|
|
buildIn(column: string, values: any[]): string {
|
|
if (values.length === 0) {
|
|
return 'FALSE'; // Empty IN clause
|
|
}
|
|
return `${column} = ANY(${this.addParam(values)})`;
|
|
}
|
|
|
|
/**
|
|
* Build NOT IN clause
|
|
*/
|
|
buildNotIn(column: string, values: any[]): string {
|
|
if (values.length === 0) {
|
|
return 'TRUE'; // Empty NOT IN clause
|
|
}
|
|
return `${column} != ALL(${this.addParam(values)})`;
|
|
}
|
|
|
|
/**
|
|
* Build equals condition
|
|
*/
|
|
buildEquals(column: string, value: any): string {
|
|
return `${column} = ${this.addParam(value)}`;
|
|
}
|
|
|
|
/**
|
|
* Build not equals condition
|
|
*/
|
|
buildNotEquals(column: string, value: any): string {
|
|
return `${column} != ${this.addParam(value)}`;
|
|
}
|
|
|
|
/**
|
|
* Build greater than condition
|
|
*/
|
|
buildGreaterThan(column: string, value: any): string {
|
|
return `${column} > ${this.addParam(value)}`;
|
|
}
|
|
|
|
/**
|
|
* Build greater than or equals condition
|
|
*/
|
|
buildGreaterThanOrEquals(column: string, value: any): string {
|
|
return `${column} >= ${this.addParam(value)}`;
|
|
}
|
|
|
|
/**
|
|
* Build less than condition
|
|
*/
|
|
buildLessThan(column: string, value: any): string {
|
|
return `${column} < ${this.addParam(value)}`;
|
|
}
|
|
|
|
/**
|
|
* Build less than or equals condition
|
|
*/
|
|
buildLessThanOrEquals(column: string, value: any): string {
|
|
return `${column} <= ${this.addParam(value)}`;
|
|
}
|
|
|
|
/**
|
|
* Build BETWEEN condition
|
|
*/
|
|
buildBetween(column: string, from: any, to: any): string {
|
|
return `${column} BETWEEN ${this.addParam(from)} AND ${this.addParam(to)}`;
|
|
}
|
|
|
|
/**
|
|
* Build IS NULL condition
|
|
*/
|
|
buildIsNull(column: string): string {
|
|
return `${column} IS NULL`;
|
|
}
|
|
|
|
/**
|
|
* Build IS NOT NULL condition
|
|
*/
|
|
buildIsNotNull(column: string): string {
|
|
return `${column} IS NOT NULL`;
|
|
}
|
|
|
|
/**
|
|
* Build UUID equals condition with validation
|
|
*/
|
|
buildUUIDEquals(column: string, uuid: string): string {
|
|
if (!isValidUUID(uuid)) {
|
|
throw new Error(`Invalid UUID: ${uuid}`);
|
|
}
|
|
return `${column} = ${this.addParam(uuid)}`;
|
|
}
|
|
|
|
/**
|
|
* Build UUID IN clause with validation
|
|
*/
|
|
buildUUIDIn(column: string, uuids: string[]): string {
|
|
for (const uuid of uuids) {
|
|
if (!isValidUUID(uuid)) {
|
|
throw new Error(`Invalid UUID: ${uuid}`);
|
|
}
|
|
}
|
|
return this.buildIn(column, uuids);
|
|
}
|
|
|
|
/**
|
|
* Add a condition to the internal conditions array
|
|
*/
|
|
addCondition(condition: string): this {
|
|
this.conditions.push(condition);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Add a condition if value is truthy
|
|
*/
|
|
addConditionIf(condition: string, value: any): this {
|
|
if (value !== undefined && value !== null && value !== '') {
|
|
this.conditions.push(condition);
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Build WHERE clause from accumulated conditions
|
|
*/
|
|
buildWhereClause(separator = ' AND '): string {
|
|
if (this.conditions.length === 0) {
|
|
return '';
|
|
}
|
|
return `WHERE ${this.conditions.join(separator)}`;
|
|
}
|
|
|
|
/**
|
|
* Get all parameters
|
|
*/
|
|
getParams(): any[] {
|
|
return this.params;
|
|
}
|
|
|
|
/**
|
|
* Get conditions array
|
|
*/
|
|
getConditions(): string[] {
|
|
return this.conditions;
|
|
}
|
|
|
|
/**
|
|
* Reset builder state
|
|
*/
|
|
reset(): this {
|
|
this.params = [];
|
|
this.paramIndex = 1;
|
|
this.conditions = [];
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Clone builder (useful for subqueries)
|
|
*/
|
|
clone(): SafeQueryBuilder {
|
|
const clone = new SafeQueryBuilder();
|
|
clone.params = [...this.params];
|
|
clone.paramIndex = this.paramIndex;
|
|
clone.conditions = [...this.conditions];
|
|
return clone;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new SafeQueryBuilder instance
|
|
*/
|
|
export function createQueryBuilder(): SafeQueryBuilder {
|
|
return new SafeQueryBuilder();
|
|
}
|
|
|
|
/**
|
|
* Build a simple SELECT query
|
|
*/
|
|
export function buildSelectQuery(
|
|
table: string,
|
|
columns: string[],
|
|
builder: SafeQueryBuilder,
|
|
options?: {
|
|
orderBy?: string;
|
|
orderDirection?: 'ASC' | 'DESC';
|
|
limit?: number;
|
|
offset?: number;
|
|
}
|
|
): { query: string; params: any[] } {
|
|
const columnsStr = columns.join(', ');
|
|
const whereClause = builder.buildWhereClause();
|
|
|
|
let query = `SELECT ${columnsStr} FROM ${table} ${whereClause}`;
|
|
|
|
if (options?.orderBy) {
|
|
const direction = options.orderDirection || 'DESC';
|
|
query += ` ORDER BY ${options.orderBy} ${direction}`;
|
|
}
|
|
|
|
if (options?.limit) {
|
|
query += ` LIMIT ${builder.addParam(options.limit)}`;
|
|
}
|
|
|
|
if (options?.offset) {
|
|
query += ` OFFSET ${builder.addParam(options.offset)}`;
|
|
}
|
|
|
|
return { query, params: builder.getParams() };
|
|
}
|
|
|
|
/**
|
|
* Build a COUNT query
|
|
*/
|
|
export function buildCountQuery(
|
|
table: string,
|
|
builder: SafeQueryBuilder
|
|
): { query: string; params: any[] } {
|
|
const whereClause = builder.buildWhereClause();
|
|
const query = `SELECT COUNT(*) as count FROM ${table} ${whereClause}`;
|
|
return { query, params: builder.getParams() };
|
|
}
|