Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useConfigContext, useDownload, useIsProjectOwner } from "@/lib/hooks";
import { useProjectArtifacts } from "@/lib/api/projects/hooks";
import { useProjectContext } from "@/lib/providers";
import type { ArtifactInfo, Project } from "@/lib/types";
import { formatRelativeTime, validateFileSizes } from "@/lib/utils";
import { formatRelativeTime, validateFileSizes, validateBatchUploadSize, validateProjectSizeLimit, calculateTotalFileSize } from "@/lib/utils";

import { ArtifactBar } from "../chat/artifact";
import { FileDetails } from "../chat/file";
Expand All @@ -27,8 +27,10 @@ export const KnowledgeSection: React.FC<KnowledgeSectionProps> = ({ project }) =
const { onDownload } = useDownload(project.id);
const { validationLimits } = useConfigContext();

// Get max upload size from config - if not available, skip client-side validation
const maxUploadSizeBytes = validationLimits?.maxUploadSizeBytes;
// Get validation limits from config - if not available, skip client-side validation
const maxPerFileUploadSizeBytes = validationLimits?.maxPerFileUploadSizeBytes;
const maxBatchUploadSizeBytes = validationLimits?.maxBatchUploadSizeBytes;
const maxProjectSizeBytes = validationLimits?.maxProjectSizeBytes;

const [filesToUpload, setFilesToUpload] = useState<FileList | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
Expand All @@ -48,13 +50,30 @@ export const KnowledgeSection: React.FC<KnowledgeSectionProps> = ({ project }) =
});
}, [artifacts]);

// Validate file sizes before showing upload dialog
// if maxUploadSizeBytes is not configured, validation is skipped and backend handles it
const currentProjectArtifactSizeBytes = React.useMemo(() => {
return calculateTotalFileSize(artifacts);
}, [artifacts]);

const handleValidateFileSizes = useCallback(
(files: FileList) => {
return validateFileSizes(files, { maxSizeBytes: maxUploadSizeBytes });
const fileSizeResult = validateFileSizes(files, { maxSizeBytes: maxPerFileUploadSizeBytes });
if (!fileSizeResult.valid) {
return fileSizeResult;
}

const batchSizeResult = validateBatchUploadSize(files, maxBatchUploadSizeBytes);
if (!batchSizeResult.valid) {
return batchSizeResult;
}

const projectSizeLimitResult = validateProjectSizeLimit(currentProjectArtifactSizeBytes, files, maxProjectSizeBytes);
if (!projectSizeLimitResult.valid) {
return { valid: false, error: projectSizeLimitResult.error };
}

return { valid: true };
},
[maxUploadSizeBytes]
[maxPerFileUploadSizeBytes, maxBatchUploadSizeBytes, maxProjectSizeBytes, currentProjectArtifactSizeBytes]
);

const handleFileUploadChange = (files: FileList | null) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const DEFAULT_MAX_ZIP_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024;

export const ProjectImportDialog: React.FC<ProjectImportDialogProps> = ({ open, onOpenChange, onImport }) => {
const { validationLimits } = useConfigContext();
const maxUploadSizeBytes = validationLimits?.maxUploadSizeBytes;
const maxPerFileUploadSizeBytes = validationLimits?.maxPerFileUploadSizeBytes;
const maxZipUploadSizeBytes = validationLimits?.maxZipUploadSizeBytes ?? DEFAULT_MAX_ZIP_UPLOAD_SIZE_BYTES;

const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null);
Expand Down Expand Up @@ -129,7 +129,7 @@ export const ProjectImportDialog: React.FC<ProjectImportDialogProps> = ({ open,
}
}

const isOversized = maxUploadSizeBytes ? size > maxUploadSizeBytes : false;
const isOversized = maxPerFileUploadSizeBytes ? size > maxPerFileUploadSizeBytes : false;

const artifactInfo: ArtifactPreviewInfo = { name: filename, size, isOversized };
artifacts.push(artifactInfo);
Expand Down Expand Up @@ -273,11 +273,11 @@ export const ProjectImportDialog: React.FC<ProjectImportDialogProps> = ({ open,
</div>

{/* Warning for oversized artifacts */}
{projectPreview.oversizedArtifacts.length > 0 && maxUploadSizeBytes && (
{projectPreview.oversizedArtifacts.length > 0 && maxPerFileUploadSizeBytes && (
<div className="mt-2">
<MessageBanner
variant="warning"
message={`${projectPreview.oversizedArtifacts.length} ${projectPreview.oversizedArtifacts.length === 1 ? "file exceeds" : "files exceed"} the maximum size of ${formatBytes(maxUploadSizeBytes)} and will be skipped during import: ${projectPreview.oversizedArtifacts
message={`${projectPreview.oversizedArtifacts.length} ${projectPreview.oversizedArtifacts.length === 1 ? "file exceeds" : "files exceed"} the maximum size of ${formatBytes(maxPerFileUploadSizeBytes)} and will be skipped during import: ${projectPreview.oversizedArtifacts
.slice(0, 3)
.map(a => a.name)
.join(", ")}${projectPreview.oversizedArtifacts.length > 3 ? ` and ${projectPreview.oversizedArtifacts.length - 3} more` : ""}`}
Expand Down
4 changes: 3 additions & 1 deletion client/webui/frontend/src/lib/contexts/ConfigContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ export interface ValidationLimits {
projectNameMax?: number;
projectDescriptionMax?: number;
projectInstructionsMax?: number;
maxUploadSizeBytes?: number;
maxPerFileUploadSizeBytes?: number;
maxBatchUploadSizeBytes?: number;
maxZipUploadSizeBytes?: number;
maxProjectSizeBytes?: number;
}

export interface ConfigContextValue {
Expand Down
105 changes: 86 additions & 19 deletions client/webui/frontend/src/lib/utils/file-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* File validation utilities for consistent file size validation across the application.
*/

import { formatBytes } from "./format";

export interface FileSizeValidationResult {
valid: boolean;
error?: string;
Expand All @@ -19,6 +21,14 @@ export interface FileSizeValidationOptions {
maxFilesToList?: number;
}

export interface ProjectSizeLimitValidationResult {
valid: boolean;
error?: string;
currentSize: number;
newSize: number;
totalSize: number;
}

/**
* Validates file sizes against a maximum limit.
*
Expand Down Expand Up @@ -56,25 +66,25 @@ export function validateFileSizes(files: FileList | File[], options: FileSizeVal
}

// Build error message
const maxSizeMB = (maxSizeBytes / (1024 * 1024)).toFixed(0);
const maxSizeWithUnit = formatBytes(maxSizeBytes, 0);
let errorMsg: string;

if (oversizedFiles.length === 1) {
const file = oversizedFiles[0];
if (includeFileSizes) {
const fileSizeMB = (file.size / (1024 * 1024)).toFixed(2);
errorMsg = `File "${file.name}" (${fileSizeMB} MB) exceeds the maximum size of ${maxSizeMB} MB.`;
const fileSizeWithUnit = formatBytes(file.size, 2);
errorMsg = `File "${file.name}" (${fileSizeWithUnit}) exceeds the maximum size of ${maxSizeWithUnit}.`;
} else {
errorMsg = `File "${file.name}" exceeds the maximum size of ${maxSizeMB} MB.`;
errorMsg = `File "${file.name}" exceeds the maximum size of ${maxSizeWithUnit}.`;
}
} else {
const fileList = oversizedFiles.slice(0, maxFilesToList);
const fileNames = includeFileSizes ? fileList.map(f => `${f.name} (${(f.size / (1024 * 1024)).toFixed(2)} MB)`) : fileList.map(f => f.name);
const fileNames = includeFileSizes ? fileList.map(f => `${f.name} (${formatBytes(f.size, 2)})`) : fileList.map(f => f.name);

const remaining = oversizedFiles.length - maxFilesToList;
const suffix = remaining > 0 ? ` and ${remaining} more` : "";

errorMsg = `${oversizedFiles.length} files exceed the maximum size of ${maxSizeMB} MB: ${fileNames.join(", ")}${suffix}`;
errorMsg = `${oversizedFiles.length} files exceed the maximum size of ${maxSizeWithUnit}: ${fileNames.join(", ")}${suffix}`;
}

return {
Expand All @@ -85,20 +95,42 @@ export function validateFileSizes(files: FileList | File[], options: FileSizeVal
}

/**
* Formats a file size in bytes to a human-readable string.
* Validates that the batch upload size doesn't exceed the limit.
* This is independent of the total project size.
*
* @param bytes - File size in bytes
* @param decimals - Number of decimal places (default: 2)
* @returns Formatted string like "1.5 MB" or "500 KB"
* @param files - FileList or array of Files to validate
* @param maxBatchUploadSizeBytes - Maximum batch upload size limit
* @returns Validation result with error message if batch exceeds limit
*/
export function formatFileSize(bytes: number, decimals: number = 2): string {
if (bytes === 0) return "0 Bytes";
export function validateBatchUploadSize(files: FileList | File[], maxBatchUploadSizeBytes?: number): FileSizeValidationResult {
if (!maxBatchUploadSizeBytes) {
return { valid: true };
}

const totalBatchSize = calculateTotalFileSize(files);

const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
if (totalBatchSize <= maxBatchUploadSizeBytes) {
return { valid: true };
}

const totalBatchWithUnit = formatBytes(totalBatchSize, 2);
const limitWithUnit = formatBytes(maxBatchUploadSizeBytes, 0);

return {
valid: false,
error: `Batch upload size (${totalBatchWithUnit}) exceeds limit of ${limitWithUnit}. Please upload fewer files at once.`,
};
}

return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
/**
* Calculates the total size of multiple files in bytes.
*
* @param files - Some list of Files, or Array of objects with size property
* @returns Total size in bytes
*/
export function calculateTotalFileSize(files: FileList | File[] | Array<{ size: number }>): number {
const fileArray: Array<{ size: number }> = Array.isArray(files) ? files : Array.from(files);
return fileArray.reduce((sum, file) => sum + file.size, 0);
}

/**
Expand All @@ -123,7 +155,42 @@ export function isFileSizeValid(file: File, maxSizeBytes?: number): boolean {
* @returns Formatted error message
*/
export function createFileSizeErrorMessage(filename: string, actualSize: number, maxSize: number): string {
const actualSizeMB = (actualSize / (1024 * 1024)).toFixed(2);
const maxSizeMB = (maxSize / (1024 * 1024)).toFixed(2);
return `File "${filename}" is too large: ${actualSizeMB} MB exceeds the maximum allowed size of ${maxSizeMB} MB.`;
const actualSizeWithUnit = formatBytes(actualSize, 2);
const maxSizeWithUnit = formatBytes(maxSize, 2);
return `File "${filename}" is too large: ${actualSizeWithUnit} exceeds the maximum allowed size of ${maxSizeWithUnit}.`;
}

/**
* Validates total project size: existing files + new files <= maxProjectSizeBytes
* This enforces a project-level storage limit, not a per-request limit.
*
* @param currentProjectSizeBytes - Current total size of project artifacts in bytes
* @param newFiles - FileList or array of Files to be uploaded
* @param maxProjectSizeBytes - Maximum total project size limit
* @returns Validation result with error message if limit would be exceeded
*/
export function validateProjectSizeLimit(currentProjectSizeBytes: number, newFiles: FileList | File[], maxProjectSizeBytes?: number): ProjectSizeLimitValidationResult {
const newSize = calculateTotalFileSize(newFiles);
const totalSize = currentProjectSizeBytes + newSize;

if (!maxProjectSizeBytes) {
return { valid: true, currentSize: currentProjectSizeBytes, newSize, totalSize };
}

if (totalSize <= maxProjectSizeBytes) {
return { valid: true, currentSize: currentProjectSizeBytes, newSize, totalSize };
}

const currentWithUnit = formatBytes(currentProjectSizeBytes, 2);
const newWithUnit = formatBytes(newSize, 2);
const totalWithUnit = formatBytes(totalSize, 2);
const limitWithUnit = formatBytes(maxProjectSizeBytes, 0);

return {
valid: false,
currentSize: currentProjectSizeBytes,
newSize,
totalSize,
error: `Project size limit exceeded. Current: ${currentWithUnit}, New files: ${newWithUnit}, Total: ${totalWithUnit} exceeds limit of ${limitWithUnit}.`,
};
}
37 changes: 29 additions & 8 deletions src/solace_agent_mesh/gateway/base/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
get_gateway_response_subscription_topic,
get_gateway_status_subscription_topic,
)
from .. import constants

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -77,14 +78,14 @@ class BaseGatewayComponent(ComponentBase):
"name": "gateway_max_artifact_resolve_size_bytes",
"required": False,
"type": "integer",
"default": 104857600, # 100MB
"default": constants.DEFAULT_MAX_ARTIFACT_RESOLVE_SIZE_BYTES,
"description": "Maximum size of an individual artifact's raw content for 'artifact_content' embeds and max total accumulated size for a parent artifact after internal recursive resolution.",
},
{
"name": "gateway_recursive_embed_depth",
"required": False,
"type": "integer",
"default": 12,
"default": constants.DEFAULT_GATEWAY_RECURSIVE_EMBED_DEPTH,
"description": "Maximum depth for recursively resolving 'artifact_content' embeds within files.",
},
{
Expand All @@ -104,16 +105,30 @@ class BaseGatewayComponent(ComponentBase):
"name": "gateway_max_message_size_bytes",
"required": False,
"type": "integer",
"default": 10_000_000, # 10MB
"default": constants.DEFAULT_GATEWAY_MAX_MESSAGE_SIZE_BYTES,
"description": "Maximum allowed message size in bytes for messages published by the gateway.",
},
{
"name": "gateway_max_upload_size_bytes",
"required": False,
"type": "integer",
"default": 52428800, # 50MB
"default": constants.DEFAULT_MAX_PER_FILE_UPLOAD_SIZE_BYTES,
"description": "Maximum file upload size in bytes. Validated before reading file content to prevent memory exhaustion.",
},
{
"name": "gateway_max_project_size_bytes",
"required": False,
"type": "integer",
"default": constants.DEFAULT_MAX_PROJECT_SIZE_BYTES,
"description": "Maximum total upload size limit per project in bytes.",
},
{
"name": "gateway_max_batch_upload_size_bytes",
"required": False,
"type": "integer",
"default": constants.DEFAULT_MAX_BATCH_UPLOAD_SIZE_BYTES,
"description": "Maximum total size in bytes for all files in a single batch upload request.",
},
# --- Default User Identity Configuration ---
{
"name": "default_user_identity",
Expand Down Expand Up @@ -274,7 +289,7 @@ def __init__(self, app_info: Dict[str, Any], **kwargs):
)

new_size_limit_key = "gateway_max_artifact_resolve_size_bytes"
default_new_size_limit = 104857600
default_new_size_limit = constants.DEFAULT_MAX_ARTIFACT_RESOLVE_SIZE_BYTES
old_size_limit_key = "gateway_artifact_content_limit_bytes"

new_value = resolved_app_config_block.get(new_size_limit_key)
Expand All @@ -299,16 +314,22 @@ def __init__(self, app_info: Dict[str, Any], **kwargs):
self.gateway_max_artifact_resolve_size_bytes = default_new_size_limit

self.gateway_recursive_embed_depth: int = resolved_app_config_block.get(
"gateway_recursive_embed_depth", 12
"gateway_recursive_embed_depth", constants.DEFAULT_GATEWAY_RECURSIVE_EMBED_DEPTH
)
self.artifact_handling_mode: str = resolved_app_config_block.get(
"artifact_handling_mode", "reference"
)
self.gateway_max_message_size_bytes: int = resolved_app_config_block.get(
"gateway_max_message_size_bytes", 10_000_000
"gateway_max_message_size_bytes", constants.DEFAULT_GATEWAY_MAX_MESSAGE_SIZE_BYTES
)
self.gateway_max_upload_size_bytes: int = resolved_app_config_block.get(
"gateway_max_upload_size_bytes", 52428800
"gateway_max_upload_size_bytes", constants.DEFAULT_MAX_PER_FILE_UPLOAD_SIZE_BYTES
)
self.gateway_max_project_size_bytes: int = resolved_app_config_block.get(
"gateway_max_project_size_bytes", constants.DEFAULT_MAX_PROJECT_SIZE_BYTES
)
self.gateway_max_batch_upload_size_bytes: int = resolved_app_config_block.get(
"gateway_max_batch_upload_size_bytes", constants.DEFAULT_MAX_BATCH_UPLOAD_SIZE_BYTES
)

modified_app_info = app_info.copy()
Expand Down
28 changes: 28 additions & 0 deletions src/solace_agent_mesh/gateway/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""
Shared constants for the HTTP/SSE gateway.

This module contains configuration defaults that are shared across
multiple components to avoid duplication and ensure consistency.
"""

# ===== ARTIFACT AND MESSAGE SIZE LIMITS =====

# Artifact prefix
ARTIFACTS_PREFIX = 'artifacts/'

# Artifact content resolution limits
DEFAULT_MAX_ARTIFACT_RESOLVE_SIZE_BYTES = 104857600 # 100MB - max size for artifact content embeds

# Recursive embed resolution limits
DEFAULT_GATEWAY_RECURSIVE_EMBED_DEPTH = 12 # Maximum depth for resolving artifact_content embeds

# Message size limits
DEFAULT_GATEWAY_MAX_MESSAGE_SIZE_BYTES = 10_000_000 # 10MB - max message size for gateway publishing

# ===== FILE UPLOAD SIZE LIMITS =====

# Production defaults
DEFAULT_MAX_PER_FILE_UPLOAD_SIZE_BYTES = 52428800 # 50MB - per-file upload limit
DEFAULT_MAX_BATCH_UPLOAD_SIZE_BYTES = 104857600 # 100MB - batch upload limit (sum of files in one upload)
DEFAULT_MAX_ZIP_UPLOAD_SIZE_BYTES = 104857600 # 100MB - ZIP import limit
DEFAULT_MAX_PROJECT_SIZE_BYTES = 104857600 # 100MB - total project size limit
Loading
Loading