Files
mcp-outline-postgresql/src/tools/bulk-operations.ts
Emanuel Almeida 7c83a9e168 fix(security): Resolve 21 SQL injection vulnerabilities and add transactions
Security fixes (v1.2.2):
- Fix SQL injection in analytics.ts (16 occurrences)
- Fix SQL injection in advanced-search.ts (1 occurrence)
- Fix SQL injection in search-queries.ts (1 occurrence)
- Add validateDaysInterval(), isValidISODate(), validatePeriod() to security.ts
- Use make_interval(days => N) for safe PostgreSQL intervals
- Validate UUIDs BEFORE string construction

Transaction support:
- bulk-operations.ts: 6 atomic operations with withTransaction()
- desk-sync.ts: 2 operations with transactions
- export-import.ts: 1 operation with transaction

Rate limiting:
- Add automatic cleanup of expired entries (every 5 minutes)

Audit:
- Archive previous audit docs to docs/audits/2026-01-31-v1.2.1/
- Create new AUDIT-REQUEST.md for v1.2.2 verification

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 14:47:41 +00:00

327 lines
12 KiB
TypeScript

/**
* MCP Outline PostgreSQL - Bulk Operations Tools
* Batch operations on documents, collections, etc.
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
import { Pool, PoolClient } from 'pg';
import { BaseTool, ToolResponse } from '../types/tools.js';
import { isValidUUID } from '../utils/security.js';
/**
* Execute operations within a transaction
*/
async function withTransaction<T>(pool: Pool, callback: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
* bulk.archive_documents - Archive multiple documents
*/
const bulkArchiveDocuments: BaseTool<{ document_ids: string[] }> = {
name: 'outline_bulk_archive_documents',
description: 'Archive multiple documents at once.',
inputSchema: {
type: 'object',
properties: {
document_ids: { type: 'array', items: { type: 'string' }, description: 'Array of document IDs (UUIDs)' },
},
required: ['document_ids'],
},
handler: async (args, pgClient): Promise<ToolResponse> => {
if (!args.document_ids || args.document_ids.length === 0) throw new Error('At least one document_id required');
if (args.document_ids.length > 100) throw new Error('Maximum 100 documents per operation');
// Validate all IDs
for (const id of args.document_ids) {
if (!isValidUUID(id)) throw new Error(`Invalid document ID: ${id}`);
}
// Use transaction for atomic operation
const result = await withTransaction(pgClient, async (client) => {
return await client.query(`
UPDATE documents
SET "archivedAt" = NOW(), "updatedAt" = NOW()
WHERE id = ANY($1) AND "archivedAt" IS NULL AND "deletedAt" IS NULL
RETURNING id, title
`, [args.document_ids]);
});
return {
content: [{ type: 'text', text: JSON.stringify({
archived: result.rows,
archivedCount: result.rows.length,
requestedCount: args.document_ids.length,
message: `${result.rows.length} documents archived`
}, null, 2) }],
};
},
};
/**
* bulk.delete_documents - Soft delete multiple documents
*/
const bulkDeleteDocuments: BaseTool<{ document_ids: string[] }> = {
name: 'outline_bulk_delete_documents',
description: 'Soft delete multiple documents at once.',
inputSchema: {
type: 'object',
properties: {
document_ids: { type: 'array', items: { type: 'string' }, description: 'Array of document IDs (UUIDs)' },
},
required: ['document_ids'],
},
handler: async (args, pgClient): Promise<ToolResponse> => {
if (!args.document_ids || args.document_ids.length === 0) throw new Error('At least one document_id required');
if (args.document_ids.length > 100) throw new Error('Maximum 100 documents per operation');
for (const id of args.document_ids) {
if (!isValidUUID(id)) throw new Error(`Invalid document ID: ${id}`);
}
// Use transaction for atomic operation
const result = await withTransaction(pgClient, async (client) => {
const deletedById = await client.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`);
const userId = deletedById.rows.length > 0 ? deletedById.rows[0].id : null;
return await client.query(`
UPDATE documents
SET "deletedAt" = NOW(), "deletedById" = $2, "updatedAt" = NOW()
WHERE id = ANY($1) AND "deletedAt" IS NULL
RETURNING id, title
`, [args.document_ids, userId]);
});
return {
content: [{ type: 'text', text: JSON.stringify({
deleted: result.rows,
deletedCount: result.rows.length,
requestedCount: args.document_ids.length,
message: `${result.rows.length} documents deleted`
}, null, 2) }],
};
},
};
/**
* bulk.move_documents - Move multiple documents to collection
*/
const bulkMoveDocuments: BaseTool<{ document_ids: string[]; collection_id: string; parent_document_id?: string }> = {
name: 'outline_bulk_move_documents',
description: 'Move multiple documents to a collection or under a parent document.',
inputSchema: {
type: 'object',
properties: {
document_ids: { type: 'array', items: { type: 'string' }, description: 'Array of document IDs (UUIDs)' },
collection_id: { type: 'string', description: 'Target collection ID (UUID)' },
parent_document_id: { type: 'string', description: 'Optional parent document ID (UUID)' },
},
required: ['document_ids', 'collection_id'],
},
handler: async (args, pgClient): Promise<ToolResponse> => {
if (!args.document_ids || args.document_ids.length === 0) throw new Error('At least one document_id required');
if (args.document_ids.length > 100) throw new Error('Maximum 100 documents per operation');
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
if (args.parent_document_id && !isValidUUID(args.parent_document_id)) throw new Error('Invalid parent_document_id');
for (const id of args.document_ids) {
if (!isValidUUID(id)) throw new Error(`Invalid document ID: ${id}`);
}
// Use transaction for atomic operation
const result = await withTransaction(pgClient, async (client) => {
// Verify collection exists
const collectionCheck = await client.query(
`SELECT id FROM collections WHERE id = $1 AND "deletedAt" IS NULL`,
[args.collection_id]
);
if (collectionCheck.rows.length === 0) throw new Error('Collection not found');
return await client.query(`
UPDATE documents
SET "collectionId" = $2, "parentDocumentId" = $3, "updatedAt" = NOW()
WHERE id = ANY($1) AND "deletedAt" IS NULL
RETURNING id, title, "collectionId"
`, [args.document_ids, args.collection_id, args.parent_document_id || null]);
});
return {
content: [{ type: 'text', text: JSON.stringify({
moved: result.rows,
movedCount: result.rows.length,
targetCollectionId: args.collection_id,
message: `${result.rows.length} documents moved`
}, null, 2) }],
};
},
};
/**
* bulk.restore_documents - Restore multiple deleted documents
*/
const bulkRestoreDocuments: BaseTool<{ document_ids: string[] }> = {
name: 'outline_bulk_restore_documents',
description: 'Restore multiple soft-deleted documents.',
inputSchema: {
type: 'object',
properties: {
document_ids: { type: 'array', items: { type: 'string' }, description: 'Array of document IDs (UUIDs)' },
},
required: ['document_ids'],
},
handler: async (args, pgClient): Promise<ToolResponse> => {
if (!args.document_ids || args.document_ids.length === 0) throw new Error('At least one document_id required');
if (args.document_ids.length > 100) throw new Error('Maximum 100 documents per operation');
for (const id of args.document_ids) {
if (!isValidUUID(id)) throw new Error(`Invalid document ID: ${id}`);
}
// Use transaction for atomic operation
const result = await withTransaction(pgClient, async (client) => {
return await client.query(`
UPDATE documents
SET "deletedAt" = NULL, "deletedById" = NULL, "updatedAt" = NOW()
WHERE id = ANY($1) AND "deletedAt" IS NOT NULL
RETURNING id, title
`, [args.document_ids]);
});
return {
content: [{ type: 'text', text: JSON.stringify({
restored: result.rows,
restoredCount: result.rows.length,
requestedCount: args.document_ids.length,
message: `${result.rows.length} documents restored`
}, null, 2) }],
};
},
};
/**
* bulk.add_users_to_collection - Add multiple users to collection
*/
const bulkAddUsersToCollection: BaseTool<{ user_ids: string[]; collection_id: string; permission?: string }> = {
name: 'outline_bulk_add_users_to_collection',
description: 'Add multiple users to a collection with specified permission.',
inputSchema: {
type: 'object',
properties: {
user_ids: { type: 'array', items: { type: 'string' }, description: 'Array of user IDs (UUIDs)' },
collection_id: { type: 'string', description: 'Collection ID (UUID)' },
permission: { type: 'string', description: 'Permission level: read_write, read, admin (default: read_write)' },
},
required: ['user_ids', 'collection_id'],
},
handler: async (args, pgClient): Promise<ToolResponse> => {
if (!args.user_ids || args.user_ids.length === 0) throw new Error('At least one user_id required');
if (args.user_ids.length > 50) throw new Error('Maximum 50 users per operation');
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
for (const id of args.user_ids) {
if (!isValidUUID(id)) throw new Error(`Invalid user ID: ${id}`);
}
const permission = args.permission || 'read_write';
// Use transaction for atomic operation
const { added, skipped } = await withTransaction(pgClient, async (client) => {
const creatorResult = await client.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`);
const createdById = creatorResult.rows.length > 0 ? creatorResult.rows[0].id : args.user_ids[0];
const addedList: string[] = [];
const skippedList: string[] = [];
for (const userId of args.user_ids) {
// Check if already exists
const existing = await client.query(
`SELECT "userId" FROM collection_users WHERE "userId" = $1 AND "collectionId" = $2`,
[userId, args.collection_id]
);
if (existing.rows.length > 0) {
skippedList.push(userId);
} else {
await client.query(`
INSERT INTO collection_users ("userId", "collectionId", permission, "createdById", "createdAt", "updatedAt")
VALUES ($1, $2, $3, $4, NOW(), NOW())
`, [userId, args.collection_id, permission, createdById]);
addedList.push(userId);
}
}
return { added: addedList, skipped: skippedList };
});
return {
content: [{ type: 'text', text: JSON.stringify({
addedUserIds: added,
skippedUserIds: skipped,
addedCount: added.length,
skippedCount: skipped.length,
permission,
message: `${added.length} users added, ${skipped.length} already existed`
}, null, 2) }],
};
},
};
/**
* bulk.remove_users_from_collection - Remove multiple users from collection
*/
const bulkRemoveUsersFromCollection: BaseTool<{ user_ids: string[]; collection_id: string }> = {
name: 'outline_bulk_remove_users_from_collection',
description: 'Remove multiple users from a collection.',
inputSchema: {
type: 'object',
properties: {
user_ids: { type: 'array', items: { type: 'string' }, description: 'Array of user IDs (UUIDs)' },
collection_id: { type: 'string', description: 'Collection ID (UUID)' },
},
required: ['user_ids', 'collection_id'],
},
handler: async (args, pgClient): Promise<ToolResponse> => {
if (!args.user_ids || args.user_ids.length === 0) throw new Error('At least one user_id required');
if (args.user_ids.length > 50) throw new Error('Maximum 50 users per operation');
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
for (const id of args.user_ids) {
if (!isValidUUID(id)) throw new Error(`Invalid user ID: ${id}`);
}
// Use transaction for atomic operation
const result = await withTransaction(pgClient, async (client) => {
return await client.query(`
DELETE FROM collection_users
WHERE "userId" = ANY($1) AND "collectionId" = $2
RETURNING "userId"
`, [args.user_ids, args.collection_id]);
});
return {
content: [{ type: 'text', text: JSON.stringify({
removedUserIds: result.rows.map(r => r.userId),
removedCount: result.rows.length,
requestedCount: args.user_ids.length,
message: `${result.rows.length} users removed from collection`
}, null, 2) }],
};
},
};
export const bulkOperationsTools: BaseTool<any>[] = [
bulkArchiveDocuments, bulkDeleteDocuments, bulkMoveDocuments,
bulkRestoreDocuments, bulkAddUsersToCollection, bulkRemoveUsersFromCollection
];