Developing a File Upload Form for 1C-Bitrix
The standard bitrix:main.feedback component supports attachments but with strict limitations: no image previews, no drag-and-drop, no client-side file type validation, and no chunked upload for large files. For scenarios where a customer attaches a technical specification, product defect photos, or blueprints — a custom form is required. The task is non-trivial because Bitrix has its own file storage model (b_file, \CFile), and new uploads need to be integrated into this model rather than stored in arbitrary directories.
Upload Architecture
For forms with file attachments, a two-step process is used:
- The file is uploaded in a separate AJAX request before the form is submitted. A temporary
file_idis returned. - On final form submission, only
file_idvalues are passed — not the files themselves.
This eliminates timeouts when uploading large files and allows displaying upload progress independently for each file.
Server-Side File Upload
// /local/api/upload.php
require_once($_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php');
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit;
}
// CSRF
if (!\bitrix_sessid_check($_POST['sessid'] ?? '')) {
http_response_code(403);
exit(json_encode(['error' => 'Invalid session']));
}
$uploader = new \Local\Upload\FileUploader();
try {
$result = $uploader->handle($_FILES['file'] ?? null);
echo json_encode(['success' => true, 'file' => $result]);
} catch (\Local\Upload\UploadException $e) {
http_response_code(422);
echo json_encode(['error' => $e->getMessage()]);
}
namespace Local\Upload;
class FileUploader
{
private const ALLOWED_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
];
private const MAX_SIZE = 20 * 1024 * 1024; // 20 MB
private const MAX_FILES_PER_SESSION = 10;
public function handle(?array $file): array
{
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
throw new UploadException($this->getUploadError($file['error'] ?? -1));
}
if ($file['size'] > self::MAX_SIZE) {
throw new UploadException('File is too large. Maximum 20 MB.');
}
// Validate MIME via finfo, not by extension
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
if (!in_array($mimeType, self::ALLOWED_TYPES, true)) {
throw new UploadException('Unsupported file type: ' . htmlspecialchars($mimeType));
}
// Antivirus: if ClamAV is available
if (function_exists('cl_scanfile')) {
$scanResult = cl_scanfile($file['tmp_name']);
if ($scanResult !== CL_CLEAN) {
throw new UploadException('File failed security check.');
}
}
// Per-session file limit
$sessionKey = 'upload_count_' . session_id();
$count = (int)($_SESSION[$sessionKey] ?? 0);
if ($count >= self::MAX_FILES_PER_SESSION) {
throw new UploadException('File limit exceeded for this submission.');
}
$_SESSION[$sessionKey] = $count + 1;
// Save via Bitrix CFile
$fileId = $this->saveToStorage($file, $mimeType);
return [
'id' => $fileId,
'name' => $file['name'],
'size' => $file['size'],
'mime' => $mimeType,
'is_image' => str_starts_with($mimeType, 'image/'),
'preview' => str_starts_with($mimeType, 'image/')
? \CFile::GetPath($fileId)
: null,
];
}
private function saveToStorage(array $file, string $mimeType): int
{
$fileData = [
'name' => $file['name'],
'size' => $file['size'],
'type' => $mimeType,
'tmp_name' => $file['tmp_name'],
'error' => 0,
'MODULE_ID' => 'local.upload',
'del' => '',
'old_file' => '',
];
$fileId = \CFile::SaveFile($fileData, 'upload/forms');
if (!$fileId) {
throw new UploadException('File save error.');
}
return $fileId;
}
}
Chunked Upload for Large Files
For files over 50 MB, a standard single POST request upload is unreliable. Implement chunked upload:
class ChunkedUploader {
constructor(file, options = {}) {
this.file = file;
this.chunkSize = options.chunkSize || 5 * 1024 * 1024; // 5 MB
this.onProgress = options.onProgress || (() => {});
this.uploadId = null;
}
async upload() {
// Initialize multipart upload
const initRes = await fetch('/local/api/upload-init.php', {
method: 'POST',
body: JSON.stringify({
filename : this.file.name,
size : this.file.size,
mime : this.file.type,
sessid : BX.bitrix_sessid(),
}),
headers: { 'Content-Type': 'application/json' },
});
const { upload_id } = await initRes.json();
this.uploadId = upload_id;
const totalChunks = Math.ceil(this.file.size / this.chunkSize);
for (let i = 0; i < totalChunks; i++) {
const start = i * this.chunkSize;
const end = Math.min(start + this.chunkSize, this.file.size);
const chunk = this.file.slice(start, end);
const formData = new FormData();
formData.append('upload_id', this.uploadId);
formData.append('chunk_index', i);
formData.append('total_chunks', totalChunks);
formData.append('chunk', chunk);
await fetch('/local/api/upload-chunk.php', {
method: 'POST',
body: formData,
});
this.onProgress(Math.round((i + 1) / totalChunks * 100));
}
// Finalize
const finalRes = await fetch('/local/api/upload-finalize.php', {
method: 'POST',
body: JSON.stringify({ upload_id: this.uploadId }),
headers: { 'Content-Type': 'application/json' },
});
return finalRes.json(); // { file_id, name, size, ... }
}
}
Final Form Submission with file_id
// Final form handler
$fileIds = array_map('intval', $data['file_ids'] ?? []);
// Verify all file_ids belong to the current session
$validFileIds = $this->validateFileOwnership($fileIds);
$leadFields = [
'TITLE' => 'Request with attachments',
'PHONE' => [['VALUE' => $phone, 'VALUE_TYPE' => 'WORK']],
'STATUS_ID' => 'NEW',
];
$lead = new \CCrmLead(false);
$leadId = $lead->Add($leadFields, true);
// Attach files to the lead via activities or custom fields
foreach ($validFileIds as $fileId) {
$this->attachFileToLead($leadId, $fileId);
}
Attaching a File to a Lead: FILE-Type Custom Field
Create a custom field UF_ATTACHMENTS of type File with the MULTIPLE = Y flag:
$userTypeManager = \Bitrix\Main\UserField\TypeManager::getInstance();
$connection = \Bitrix\Main\Application::getConnection();
// Or via the interface: CRM → Settings → Custom Fields → Leads
$uft = new \CUserTypeEntity();
$uft->Add([
'ENTITY_ID' => 'CRM_LEAD',
'FIELD_NAME' => 'UF_ATTACHMENTS',
'USER_TYPE_ID' => 'file',
'MULTIPLE' => 'Y',
'MANDATORY' => 'N',
'EDIT_FORM_LABEL' => ['ru' => 'Вложения'],
'LIST_COLUMN_LABEL' => ['ru' => 'Вложения'],
]);
When updating a lead with file_id values:
$lead->Update($leadId, [
'UF_ATTACHMENTS' => $validFileIds, // array of file IDs
], true);
Scope of Work
-
local:file.uploadcomponent with drag-and-drop zone and previews - Server-side upload handler: type validation via finfo, CSRF, rate limiting
- Storage via
CFile::SaveFile()intob_file - Chunked upload for files > 10 MB
- Custom field
UF_ATTACHMENTS(FILE, MULTIPLE) in CRM - Attaching uploaded files to the created lead
- Manager notification with direct links to attachments
Timeline: basic single-attachment form — 3–5 days. Full version with drag-and-drop, previews, chunked upload, and CRM integration — 2–3 weeks.







