DEV Community

Gaurab Dhakal
Gaurab Dhakal

Posted on

How I Built a Single-File PHP File Manager That Bypasses upload_max_filesize

Most PHP file managers fail on shared cPanel hosting. Not because they are poorly built, but because they assume something you cannot do: change php.ini.

On budget shared hosting, upload_max_filesize is often locked at 2MB. max_execution_time is capped at 30 seconds. memory_limit is too low to buffer a 5GB file into PHP RAM. And Imunify360 or ModSecurity will block any request containing parameters like cmd=, exec=, or paths with ../.

I built ChunkCrate to work within those constraints. It is a single index.php file — no Composer, no npm, no Docker, no database — that uploads multi-gigabyte files by slicing them in the browser and reassembling them server-side with stream pipes.

This article walks through the architecture, the specific problems it solves, and the code that makes it work. The full source is on GitHub.


Why Standard File Uploaders Break on Shared Hosting

A standard file upload sends the entire file in one POST request. On shared hosting, three PHP configuration limits kill this approach:

; Typical shared cPanel defaults
upload_max_filesize = 2M
post_max_size = 2M
max_execution_time = 30
memory_limit = 128M
Enter fullscreen mode Exit fullscreen mode

If you try to upload a 100MB file:

  1. upload_max_filesize rejects the file before PHP even processes it.
  2. If you somehow bypass that, post_max_size limits the total POST body.
  3. If you raise both, max_execution_time kills the script mid-upload.
  4. If you raise all three, memory_limit crashes when you try to buffer the file.

You cannot change these values on shared hosting. The host owns php.ini.

The solution is chunked uploading. Instead of one large POST, the browser slices the file into smaller pieces and sends them sequentially. Each request is small enough to pass all four limits.


How Chunked Uploads Work (The 2MB Strategy)

ChunkCrate uses a 2MB chunk size. This is not arbitrary — it is the exact value that most shared hosts allow for upload_max_filesize. By matching the host limit, each chunk passes without modification.

The flow:

Browser                          Server
   │                               │
   │  File selected (5GB)          │
   │──────────────────────────────▶│
   │                               │
   │  Slice into 2MB chunks        │
   │  Chunk 1 ────────────────────▶│  Save to .fm_tmp/
   │  Chunk 2 ────────────────────▶│  Save to .fm_tmp/
   │  Chunk 3 ────────────────────▶│  Save to .fm_tmp/
   │  ...                          │  ...
   │  Chunk N ────────────────────▶│  Save to .fm_tmp/
   │                               │
   │  "assemble_file" ────────────▶│  Stream chunks into final file
   │                               │  Delete .fm_tmp/ chunks
   │                               │
Enter fullscreen mode Exit fullscreen mode

Each chunk upload is a separate AJAX request. The server writes it to a temporary file. When all chunks arrive, a final assemble_file request concatenates them using stream pipes — no RAM buffering.


The Server-Side Reassembly: Stream Pipes, Not file_get_contents

The critical part is reassembly. If you use file_get_contents() to read all chunks and concatenate them, you load the entire file into PHP memory. On a 5GB file with a 128MB memory limit, that crashes.

ChunkCrate uses fopen() and fwrite() in a 64KB loop:

function assembleChunks(string $targetPath, array $chunkPaths): bool
{
    $out = fopen($targetPath, 'wb');
    if (!$out) {
        return false;
    }

    foreach ($chunkPaths as $chunkPath) {
        $in = fopen($chunkPath, 'rb');
        if (!$in) {
            fclose($out);
            return false;
        }

        while (!feof($in)) {
            fwrite($out, fread($in, 65536));
        }

        fclose($in);
        unlink($chunkPath); // Clean up immediately
    }

    fclose($out);
    return true;
}
Enter fullscreen mode Exit fullscreen mode

Why 64KB? It is large enough for good I/O throughput, small enough to keep memory flat regardless of file size. The PHP memory footprint stays under 1MB even for a 10GB file.

Why 'wb' and 'rb'? Binary mode. On Windows servers, text mode would corrupt binary files by translating \n to \r\n.


WAF-Friendly Parameter Naming (Imunify360 & ModSecurity)

Imunify360 and ModSecurity use heuristic rules to block suspicious requests. Parameters named cmd, exec, eval, or system trigger immediate blocks. Paths containing ../ trigger directory traversal rules.

ChunkCrate avoids all of these:

Suspicious (blocked) ChunkCrate (allowed)
cmd=upload action=upload_chunk
exec=assemble action=assemble_file
path=../../etc/passwd path=/storage/file.txt
eval=... (not used)

Every parameter is a clean, descriptive, REST-style name:

$action = $_POST['action'] ?? $_GET['action'] ?? '';

switch ($action) {
    case 'upload_chunk':
        handleChunkUpload();
        break;
    case 'assemble_file':
        handleAssemble();
        break;
    case 'list':
        handleList();
        break;
    case 'mkdir':
        handleMkdir();
        break;
    case 'save_file':
        handleSaveFile();
        break;
    // ...
}
Enter fullscreen mode Exit fullscreen mode

No cmd=, no exec=, no eval=. The WAF sees normal file management operations.


Directory Traversal Protection with realpath()

Every path parameter must be validated before use. ChunkCrate uses realpath() to resolve the canonical path, then checks that it starts with the storage root:

function isPathSafe(string $path): bool
{
    $root = realpath(FM_BASE_DIR);
    $resolved = realpath($path);

    if ($resolved === false) {
        return false;
    }

    // Ensure the resolved path is inside the storage root
    return str_starts_with($resolved, $root . DIRECTORY_SEPARATOR)
        || $resolved === $root;
}
Enter fullscreen mode Exit fullscreen mode

This blocks:

  • ../ traversal
  • Null byte injection (file.php\0.txt)
  • Symlink escapes

If realpath() returns false, the path does not exist — reject it. If the resolved path is outside the storage root, reject it.


Isolating Uploaded Chunks with .htaccess

Chunks are stored in .fm_tmp/ inside the storage directory. If someone guesses the chunk URL, they could download partial file data. ChunkCrate generates an .htaccess file in .fm_tmp/ on first run:

# Auto-generated by ChunkCrate
Deny from all
php_flag engine off
<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteRule .* - [F,L]
</IfModule>
Enter fullscreen mode Exit fullscreen mode

This works on Apache and LiteSpeed (the most common cPanel web servers). It does three things:

  1. Deny from all blocks direct HTTP access to chunk files.
  2. php_flag engine off prevents PHP execution in the temp directory.
  3. RewriteRule .* - [F,L] provides a second layer of denial.

CSRF Protection and Session Security

Every mutating request (upload, delete, rename, chmod) requires a session-bound CSRF token:

// Generate token on login
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));

// Validate on every request
function validateCsrf(): void
{
    $token = $_POST['csrf_token']
        ?? $_SERVER['HTTP_X_CSRF_TOKEN']
        ?? '';

    if (!hash_equals($_SESSION['csrf_token'], $token)) {
        http_response_code(403);
        exit('Invalid CSRF token');
    }
}
Enter fullscreen mode Exit fullscreen mode

The token is sent in the X-CSRF-Token header for AJAX requests and as a hidden field for form POSTs. hash_equals() prevents timing attacks.


Authentication: Bcrypt with Restricted File Permissions

Credentials are stored in .fm_auth.json inside the storage directory:

function saveCredentials(string $user, string $pass): void
{
    $hash = password_hash($pass, PASSWORD_BCRYPT);
    $data = json_encode([
        'user' => $user,
        'hash' => $hash,
        'created' => time(),
    ]);

    file_put_contents(
        FM_BASE_DIR . '/.fm_auth.json',
        $data
    );

    chmod(FM_BASE_DIR . '/.fm_auth.json', 0600);
}
Enter fullscreen mode Exit fullscreen mode

0600 permissions mean only the PHP process owner can read the file. Even if another hosting user finds the path, they cannot access it.


The Frontend: Vanilla JS Chunked Uploader

No jQuery. No Axios. No libraries. The uploader uses the HTML5 File API:

const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB

async function uploadFile(file) {
    const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
    const uploadId = generateUploadId();

    for (let i = 0; i < totalChunks; i++) {
        const start = i * CHUNK_SIZE;
        const end = Math.min(start + CHUNK_SIZE, file.size);
        const chunk = file.slice(start, end);

        const formData = new FormData();
        formData.append('action', 'upload_chunk');
        formData.append('upload_id', uploadId);
        formData.append('chunk_index', i);
        formData.append('total_chunks', totalChunks);
        formData.append('chunk', chunk);
        formData.append('csrf_token', CSRF_TOKEN);

        await uploadWithRetry(formData, i, totalChunks);
    }

    // Assemble
    const assembleData = new FormData();
    assembleData.append('action', 'assemble_file');
    assembleData.append('upload_id', uploadId);
    assembleData.append('filename', file.name);
    assembleData.append('csrf_token', CSRF_TOKEN);

    await fetch('index.php', {
        method: 'POST',
        body: assembleData,
    });
}
Enter fullscreen mode Exit fullscreen mode

Exponential Retry on Failed Chunks

Network hiccups happen. A chunk may fail due to a temporary server timeout or packet loss. ChunkCrate retries up to 3 times with exponential backoff:

async function uploadWithRetry(formData, chunkIndex, totalChunks, attempt = 1) {
    try {
        const response = await fetch('index.php', {
            method: 'POST',
            body: formData,
        });

        if (!response.ok) {
            throw new Error(`HTTP ${response.status}`);
        }

        return await response.json();
    } catch (error) {
        if (attempt >= 3) {
            throw new Error(
                `Chunk ${chunkIndex + 1}/${totalChunks} failed after 3 attempts`
            );
        }

        const delay = Math.pow(2, attempt - 1) * 1000; // 1s, 2s, 4s
        await sleep(delay);

        return uploadWithRetry(formData, chunkIndex, totalChunks, attempt + 1);
    }
}
Enter fullscreen mode Exit fullscreen mode

The delays are 1s, 2s, 4s. If all three fail, the upload aborts with a clear error message.


Live Progress Metrics (Speed, ETA, Percentage)

The progress drawer shows real-time transfer speed and estimated time of arrival:

const startTime = Date.now();
let bytesUploaded = 0;

function updateProgress(chunkSize) {
    bytesUploaded += chunkSize;

    const elapsed = (Date.now() - startTime) / 1000; // seconds
    const speed = bytesUploaded / elapsed; // bytes per second
    const remaining = file.size - bytesUploaded;
    const eta = remaining / speed; // seconds

    const percent = (bytesUploaded / file.size) * 100;

    progressBar.style.width = `${percent}%`;
    percentLabel.textContent = `${percent.toFixed(1)}%`;
    speedLabel.textContent = `${(speed / 1024 / 1024).toFixed(2)} MB/s`;
    etaLabel.textContent = formatTime(eta);
}
Enter fullscreen mode Exit fullscreen mode

This gives users the same experience they expect from native file managers.


What ChunkCrate Includes (Full Feature Set)

The single index.php file delivers a complete file management experience:

Feature Implementation
2MB chunked uploads HTML5 File API + AJAX
Exponential retry 3 attempts, 1s/2s/4s backoff
In-browser code editor Monospace font, Ctrl+S to save
Media lightbox Images, video, audio, PDF preview
ZIP create/extract Optional zip extension
File permissions View and change octal chmod
Multi-select batch actions Checkboxes, select all, batch delete/zip
Rename, move, duplicate Full file operations
Streaming downloads 64KB chunks, no memory spike
Real-time filter Instant search as you type
PWA install Standalone desktop/mobile app
Zero dependencies No CDN, no jQuery, no Bootstrap

Installation (60 Seconds)

  1. Download index.php from the GitHub repository.

  2. Upload it to any directory on your server:

cp index.php /home/user/public_html/filemanager/index.php
chmod 644 /home/user/public_html/filemanager/index.php
chmod 755 /home/user/public_html/filemanager
Enter fullscreen mode Exit fullscreen mode
  1. Open the URL in your browser:
https://yourdomain.com/filemanager/
Enter fullscreen mode Exit fullscreen mode
  1. Log in with the default credentials:
Username: admin
Password: admin123
Enter fullscreen mode Exit fullscreen mode
  1. Change the password immediately by clicking your username badge in the header.

The storage/ directory is created automatically on first run, with all protective .htaccess files in place.


What I Learned Building This

Single-file architecture is a constraint, not a limitation. It forces you to be disciplined about scope. Every feature must earn its place. The result is a 4,000-line index.php that does everything a multi-file project would, but with zero installation friction.

WAF compatibility is a design requirement, not an afterthought. Choosing parameter names like upload_chunk instead of cmd is not optional if you want to work on shared hosting. The WAF is not the enemy — it is a constraint you design around.

Stream pipes beat memory buffers every time. The fopen/fwrite loop is not clever. It is the correct way to handle large files in PHP. The file_get_contents approach is what breaks on shared hosting.

Chunked uploading is the universal solution. It works on every host, every PHP version, every WAF. The 2MB chunk size is the magic number because it matches the most restrictive shared hosting configuration.


Try It and Contribute

Repository: github.com/gaupalawes/chunkcrate

If you are on shared cPanel hosting and hitting upload limits, ChunkCrate is built for exactly that problem. Download index.php, drop it in a folder, and you have a full file manager in under 60 seconds.

Contributions welcome:

  • WAF compatibility reports (which hosts, which rules)
  • PHP 8.3+ testing
  • Browser compatibility reports
  • Translations

Open an issue on GitHub or submit a pull request. The project is MIT-licensed.


FAQ

Does ChunkCrate work on PHP 7.4?

Yes. ChunkCrate supports PHP 7.4 through 8.3+. It uses str_starts_with() with a polyfill for PHP 7.4 compatibility.

Does it work behind Cloudflare?

Yes. The chunked upload approach is compatible with Cloudflare's 100MB upload limit because each chunk is only 2MB.

Can I manage files outside public_html?

Yes. Change FM_BASE_DIR in index.php to any absolute path. The directory traversal protection ensures users cannot escape that root.

Is the zip extension required?

No. ZIP create and extract features are optional. The rest of the file manager works without it.

How large can uploaded files be?

Theoretically unlimited. ChunkCrate uploads in 2MB pieces, so file size is limited only by available disk space, not PHP configuration.

Top comments (0)