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
If you try to upload a 100MB file:
-
upload_max_filesizerejects the file before PHP even processes it. - If you somehow bypass that,
post_max_sizelimits the total POST body. - If you raise both,
max_execution_timekills the script mid-upload. - If you raise all three,
memory_limitcrashes 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
│ │
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;
}
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;
// ...
}
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;
}
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>
This works on Apache and LiteSpeed (the most common cPanel web servers). It does three things:
-
Deny from allblocks direct HTTP access to chunk files. -
php_flag engine offprevents PHP execution in the temp directory. -
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');
}
}
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);
}
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,
});
}
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);
}
}
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);
}
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)
Download
index.phpfrom the GitHub repository.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
- Open the URL in your browser:
https://yourdomain.com/filemanager/
- Log in with the default credentials:
Username: admin
Password: admin123
- 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)