Developing a file upload form for 1C-Bitrix

Our company is engaged in the development, support and maintenance of Bitrix and Bitrix24 solutions of any complexity. From simple one-page sites to complex online stores, CRM systems with 1C and telephony integration. The experience of developers is confirmed by certificates from the vendor.
Our competencies:
Development stages
Latest works
  • image_website-b2b-advance_0.png
    B2B ADVANCE company website development
    1175
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Website development for FIXPER company
    811
  • image_bitrix-bitrix-24-1c_development_of_an_online_appointment_booking_widget_for_a_medical_center_594_0.webp
    Development based on Bitrix, Bitrix24, 1C for the company Development of an Online Appointment Booking Widget for a Medical Center
    564
  • image_bitrix-bitrix-24-1c_mirsanbel_458_0.webp
    Development based on 1C Enterprise for MIRSANBEL
    747
  • image_crm_dolbimby_434_0.webp
    Website development on CRM Bitrix24 for DOLBIMBY
    655
  • image_crm_technotorgcomplex_453_0.webp
    Development based on Bitrix24 for the company TECHNOTORGKOMPLEKS
    976

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:

  1. The file is uploaded in a separate AJAX request before the form is submitted. A temporary file_id is returned.
  2. On final form submission, only file_id values 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.upload component with drag-and-drop zone and previews
  • Server-side upload handler: type validation via finfo, CSRF, rate limiting
  • Storage via CFile::SaveFile() into b_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.