upload_file.ts•7.32 kB
import { BaseTool } from '../base';
import { QuickbaseClient } from '../../client/quickbase';
import { createLogger } from '../../utils/logger';
import { fileExists, getFileInfo, readFileAsBuffer } from '../../utils/file';
const logger = createLogger('UploadFileTool');
/**
* Parameters for upload_file tool
*/
export interface UploadFileParams {
/**
* The ID of the table containing the record
*/
table_id: string;
/**
* The ID of the record to attach the file to
*/
record_id: string;
/**
* The ID of the file attachment field
*/
field_id: string;
/**
* Path to the file to upload
*/
file_path: string;
/**
* Custom file name (optional, defaults to the original filename)
*/
file_name?: string;
}
/**
* Response from uploading a file
*/
export interface UploadFileResult {
/**
* The ID of the record the file was attached to
*/
recordId: string;
/**
* The ID of the field the file was attached to
*/
fieldId: string;
/**
* The ID of the table containing the record
*/
tableId: string;
/**
* The name of the uploaded file
*/
fileName: string;
/**
* The size of the uploaded file in bytes
*/
fileSize: number;
/**
* The version of the file (incremented for each upload to the same field)
*/
version?: number;
/**
* Upload timestamp
*/
uploadTime?: string;
}
/**
* Tool for uploading a file to a field in a Quickbase record
*/
export class UploadFileTool extends BaseTool<UploadFileParams, UploadFileResult> {
public name = 'upload_file';
public description = 'Uploads a file to a field in a Quickbase record';
/**
* Parameter schema for upload_file
*/
public paramSchema = {
type: 'object',
properties: {
table_id: {
type: 'string',
description: 'The ID of the table (must be a file attachment field)'
},
record_id: {
type: 'string',
description: 'The ID of the record'
},
field_id: {
type: 'string',
description: 'The ID of the field (must be a file attachment field)'
},
file_path: {
type: 'string',
description: 'Path to the file to upload'
},
file_name: {
type: 'string',
description: 'Custom file name (optional, defaults to the original filename)'
}
},
required: ['table_id', 'record_id', 'field_id', 'file_path']
};
/**
* Constructor
* @param client Quickbase client
*/
constructor(client: QuickbaseClient) {
super(client);
}
/**
* Run the upload_file tool
* @param params Tool parameters
* @returns Upload result
*/
protected async run(params: UploadFileParams): Promise<UploadFileResult> {
const { table_id, record_id, field_id, file_path, file_name } = params;
logger.info('Uploading file to Quickbase record', {
tableId: table_id,
recordId: record_id,
fieldId: field_id,
filePath: file_path
});
// Check if the file exists
if (!fileExists(file_path)) {
throw new Error(`File not found: ${file_path}`);
}
// Get file information
const fileInfo = getFileInfo(file_path);
if (!fileInfo) {
throw new Error(`Unable to get file information: ${file_path}`);
}
// Validate file size (max 100MB)
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
if (fileInfo.size > MAX_FILE_SIZE) {
throw new Error(`File size ${fileInfo.size} bytes exceeds maximum allowed size of ${MAX_FILE_SIZE} bytes`);
}
// Secure file path validation - prevent directory traversal attacks
const path = await import('path');
const fs = await import('fs');
// First, validate the input path before any resolution
if (!file_path || typeof file_path !== 'string') {
throw new Error('Invalid file path: must be a non-empty string');
}
// Reject obvious traversal attempts immediately
if (file_path.includes('..') || file_path.startsWith('/') || file_path.includes('\\')) {
throw new Error('Invalid file path: directory traversal detected');
}
// Define allowed working directory (current directory only)
const workingDir = process.cwd();
let resolvedPath: string;
try {
// Resolve the path relative to working directory
resolvedPath = path.resolve(workingDir, file_path);
} catch (error) {
throw new Error('Invalid file path format');
}
// Critical security check: ensure resolved path is within working directory
if (!resolvedPath.startsWith(workingDir + path.sep) && resolvedPath !== workingDir) {
throw new Error('Invalid file path: access outside working directory denied');
}
// Verify file exists and is readable
try {
await fs.promises.access(resolvedPath, fs.constants.R_OK);
} catch (error) {
throw new Error(`File access denied or file does not exist: ${file_path}`);
}
// Memory-efficient file reading with size validation
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks
let fileBase64: string;
try {
if (fileInfo.size > 10 * 1024 * 1024) { // > 10MB, use streaming
logger.debug('Using streaming read for large file', { size: fileInfo.size });
// Stream the file in chunks to prevent memory overflow
const chunks: string[] = [];
const readStream = fs.createReadStream(resolvedPath, { highWaterMark: CHUNK_SIZE });
for await (const chunk of readStream) {
chunks.push((chunk as Buffer).toString('base64'));
}
fileBase64 = chunks.join('');
} else {
// Small files can be read directly
const fileBuffer = readFileAsBuffer(file_path);
if (!fileBuffer) {
throw new Error(`Unable to read file: ${file_path}`);
}
fileBase64 = fileBuffer.toString('base64');
}
} catch (error) {
throw new Error(`Failed to read file: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Prepare the file upload request
const body = {
tableId: table_id,
recordId: record_id,
fieldId: field_id,
fileName: file_name || fileInfo.name,
contentType: fileInfo.mimeType,
fileData: fileBase64
};
// Upload the file
const response = await this.client.request({
method: 'POST',
path: '/files',
body
});
if (!response.success || !response.data) {
logger.error('Failed to upload file', {
error: response.error,
tableId: table_id,
recordId: record_id,
fieldId: field_id
});
throw new Error(response.error?.message || 'Failed to upload file');
}
const fileData = response.data as Record<string, any>;
logger.info('Successfully uploaded file', {
tableId: table_id,
recordId: record_id,
fieldId: field_id,
fileName: file_name || fileInfo.name
});
return {
recordId: record_id,
fieldId: field_id,
tableId: table_id,
fileName: file_name || fileInfo.name,
fileSize: fileInfo.size,
version: fileData.version || 1,
uploadTime: new Date().toISOString()
};
}
}