Skip to content

Instantly share code, notes, and snippets.

@flashlab
Created March 16, 2025 03:19
Show Gist options
  • Save flashlab/08ac88a9e9928dd509ceb3c3b99cd6b7 to your computer and use it in GitHub Desktop.
Save flashlab/08ac88a9e9928dd509ceb3c3b99cd6b7 to your computer and use it in GitHub Desktop.
Simple curl file(s) uploading backend with php and nginx

Features

Upload file(s) with curl using the PUT or POST method

Support for specific path: https://curl.abc.com/subfolder/filename Auto-create folders if they don't exist Returns JSON with path, filename, size, and MD5

Delete file or folder with curl using the DELETE method

Delete specific file: https://curl.abc.com/subfolder/filename Clean entire folder: https://curl.abc.com/subfolder/

Download file with curl using the GET method

Get specific file: https://curl.abc.com/subfolder/filename

Security features

Path normalization to prevent directory traversal Proper error handling and status codes File size limits configurable in Nginx

Usage Examples

Upload file(s) with curl -T

# Upload to root with auto-generated filename
curl -T myfile.txt https://curl.abc.com/

# Upload to root with specific filename
curl -T myfile.txt https://curl.abc.com/desired-filename.txt

# Upload to a subfolder with auto-generated filename
curl -T myfile.txt https://curl.abc.com/subfolder/

# Upload multiple files
curl -T "{file1,file2}" https://curl.abc.com/

Upload file(s) with curl -F

# Upload with form data, keeping original filename
curl -F "[email protected];filename=newname.txt" https://curl.abc.com/subfolder/

# Upload with form data, force using specific filename from URL
curl -F "[email protected]" https://curl.abc.com/subfolder/newname.txt

# Upload multiple files
curl -F file=@file1 -F file=@file2 https://curl.abc.com/subfolder/

Delete files

# Delete a specific file
curl -X DELETE https://curl.abc.com/subfolder/myfile.txt

# Clean an entire folder
curl -X DELETE https://curl.abc.com/subfolder/

Notice

  • Ensure PHP has write permittion on uploads folder.
  • If upload multiple files, do not specify filename in the url.
  • you can use --progress-bar and cat to show the uploading process, do not use under multiple files mode.
server {
listen 80;
server_name curl.abc.com;
# Redirect HTTP to HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name curl.abc.com;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
# Base uploads directory
root /var/www/uploads;
client_max_body_size 100M; # Adjust based on your max file size needs
# Error logs
# error_log /var/log/nginx/error.log;
# PHP processing setup
location / {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/handler.php; # php handler location
fastcgi_param PATH_INFO $uri;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param QUERY_STRING $query_string;
fastcgi_pass unix:/run/php/php8.2-fpm.sock; # maybe different
fastcgi_read_timeout 300;
}
}
<?php
declare(strict_types=1);
// Base directory for all uploads
define('UPLOAD_DIR', '/var/www/uploads');
/**
* Main request handler
*/
function handleRequest(): void {
$method = $_SERVER['REQUEST_METHOD'];
$path = $_SERVER['PATH_INFO'] ?? '/';
// Normalize path to prevent directory traversal
$path = normalizePath($path);
try {
switch ($method) {
case 'GET':
handleFileDownload($path);
break;
case 'POST':
// For form uploads (-F parameter in curl)
handleFormUpload($path);
break;
case 'PUT':
// For direct file uploads (-T parameter in curl)
handleFileUpload($path);
break;
case 'DELETE':
handleFileDeletion($path);
break;
default:
throw new Exception('Method not allowed', 405);
}
} catch (Exception $e) {
header('Content-Type: application/json');
http_response_code($e->getCode() ?: 500);
echo json_encode([
'error' => $e->getMessage(),
'code' => $e->getCode()
]);
}
}
/**
* Handle file download (GET method)
*/
function handleFileDownload(string $path): void {
// Remove leading slash and trim
$path = trim($path, '/');
if (empty($path)) {
throw new Exception('No file specified', 400);
}
$targetPath = UPLOAD_DIR . '/' . $path;
// Check if file exists
if (!file_exists($targetPath)) {
throw new Exception('File not found', 404);
}
// Check if path is a directory
if (is_dir($targetPath)) {
throw new Exception('Cannot download directory', 400);
}
// Get file info
$filename = basename($targetPath);
$filesize = filesize($targetPath);
$mimeType = mime_content_type($targetPath) ?: 'application/octet-stream';
// Set headers for download
header('Content-Type: ' . $mimeType);
header('Content-Disposition: inline; filename="' . $filename . '"');
header('Content-Length: ' . $filesize);
header('Cache-Control: public, max-age=86400');
// Output file contents
readfile($targetPath);
exit;
}
/**
* Handle form-based file upload (POST method)
* This supports curl -F parameter
*/
function handleFormUpload(string $path): void {
if (empty($_FILES)) {
throw new Exception('No file uploaded', 400);
}
$results = [];
foreach ($_FILES as $fieldName => $fileInfo) {
// Handle multiple files (array structure)
if (is_array($fileInfo['name'])) {
for ($i = 0; $i < count($fileInfo['name']); $i++) {
if ($fileInfo['error'][$i] !== UPLOAD_ERR_OK) {
continue; // Skip failed uploads
}
$uploadedFilename = $fileInfo['name'][$i];
$tempPath = $fileInfo['tmp_name'][$i];
$fileResult = processUploadedFile($path, $tempPath, $uploadedFilename);
if ($fileResult) {
$results[] = $fileResult;
}
}
} else {
// Handle single file
if ($fileInfo['error'] === UPLOAD_ERR_OK) {
$uploadedFilename = $fileInfo['name'];
$tempPath = $fileInfo['tmp_name'];
$fileResult = processUploadedFile($path, $tempPath, $uploadedFilename);
if ($fileResult) {
$results[] = $fileResult;
}
}
}
}
if (empty($results)) {
throw new Exception('Failed to process uploaded files', 400);
}
// Return JSON response
header('Content-Type: application/json');
http_response_code(201); // Created
echo json_encode($results);
}
/**
* Process an uploaded file (used by form upload handler)
*
* @param string $path URL path
* @param string $tempPath Temporary file path
* @param string $uploadedFilename Original filename
* @return array|null File information or null on failure
*/
function processUploadedFile(string $path, string $tempPath, string $uploadedFilename): ?array {
// Determine the target path based on URL path
list($targetDir, $targetFilename, $relativePath) = getTargetPath($path, $uploadedFilename);
// Create directory if it doesn't exist
if (!is_dir($targetDir)) {
if (!mkdir($targetDir, 0755, true)) {
return null; // Failed to create directory
}
}
$targetPath = $targetDir . '/' . $targetFilename;
// Move uploaded file
if (!move_uploaded_file($tempPath, $targetPath)) {
return null; // Failed to move file
}
// Get file stats
$filesize = filesize($targetPath);
$md5 = md5_file($targetPath);
// Return file information with path
return [
'path' => $relativePath . '/' . $targetFilename,
'filename' => $targetFilename,
'size' => $filesize,
'md5' => $md5
];
}
/**
* Handle direct file upload (PUT method)
*/
function handleFileUpload(string $path): void {
// Read the PUT data
$putData = file_get_contents('php://input');
if ($putData === false) {
throw new Exception('Failed to read input data', 400);
}
// Determine the target path based solely on the URL path
list($targetDir, $targetFilename, $relativePath) = getTargetPath($path);
// Create directory if it doesn't exist
if (!is_dir($targetDir)) {
if (!mkdir($targetDir, 0755, true)) {
throw new Exception('Failed to create directory', 500);
}
}
$targetPath = $targetDir . '/' . $targetFilename;
// Write file
if (file_put_contents($targetPath, $putData) === false) {
throw new Exception('Failed to write file', 500);
}
// Get file stats
$filesize = filesize($targetPath);
$md5 = md5_file($targetPath);
// Return JSON response
header('Content-Type: application/json');
http_response_code(201); // Created
echo json_encode([
'path' => $relativePath . '/' . $targetFilename,
'filename' => $targetFilename,
'size' => $filesize,
'md5' => $md5
]);
}
/**
* Handle file deletion (DELETE method)
*/
function handleFileDeletion(string $path): void {
// Determine if this is a directory deletion (ends with slash)
$isDirectory = substr($path, -1) === '/';
// Normalize path for processing
$path = trim($path, '/');
$targetPath = UPLOAD_DIR . ($path ? '/' . $path : '');
if ($isDirectory) {
// Directory deletion - delete all files recursively
if (!is_dir($targetPath)) {
throw new Exception('Directory does not exist', 404);
}
// Get relative path for output
$relativePath = $path;
// Delete directory and all its contents recursively
deleteDirectoryRecursively($targetPath);
// Return JSON response
header('Content-Type: application/json');
http_response_code(200); // OK
echo json_encode([
'path' => $relativePath,
'status' => 'deleted'
]);
} else {
// File deletion
if (!file_exists($targetPath)) {
throw new Exception('File not found', 404);
}
if (is_dir($targetPath)) {
throw new Exception('Cannot delete directory without trailing slash', 400);
}
$filesize = filesize($targetPath);
$md5 = md5_file($targetPath);
$filename = basename($targetPath);
$relativePath = $path;
if (!unlink($targetPath)) {
throw new Exception('Failed to delete file', 500);
}
// Return JSON response
header('Content-Type: application/json');
http_response_code(200); // OK
echo json_encode([
'path' => $relativePath,
'filename' => $filename,
'size' => $filesize,
'md5' => $md5
]);
}
}
/**
* Recursively delete a directory and all its contents
*/
function deleteDirectoryRecursively(string $dir): void {
if (!is_dir($dir)) {
return;
}
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($files as $fileinfo) {
$action = ($fileinfo->isDir() ? 'rmdir' : 'unlink');
if (!$action($fileinfo->getRealPath())) {
// Silently continue if deletion fails
continue;
}
}
// Finally remove the directory itself
@rmdir($dir);
}
/**
* Parse path to get target directory and filename
*/
function getTargetPath(string $path, ?string $uploadedFilename = null): array {
// Check if path ends with a slash - indicating it's a directory
$isDirectory = substr($path, -1) === '/';
// Remove trailing slashes for processing
$path = trim($path, '/');
if (empty($path)) {
// Root path with no specific filename - generate a random one or use uploaded filename
$targetDir = UPLOAD_DIR;
$filename = $uploadedFilename ?? generateRandomFilename();
$relativePath = '';
} else if ($isDirectory) {
// Path ends with slash, so it's definitely a directory
$targetDir = UPLOAD_DIR . '/' . $path;
$filename = $uploadedFilename ?? generateRandomFilename();
$relativePath = $path;
} else {
// No trailing slash, the last segment is a filename
$pathParts = explode('/', $path);
$filename = array_pop($pathParts);
$dirPath = implode('/', $pathParts);
$targetDir = UPLOAD_DIR . ($dirPath ? '/' . $dirPath : '');
$relativePath = $dirPath;
}
return [$targetDir, $filename, $relativePath];
}
/**
* Generate a random filename with timestamp
*/
function generateRandomFilename(): string {
return 'upload_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4));
}
/**
* Normalize path to prevent directory traversal
*/
function normalizePath(string $path): string {
// Remember if path had a trailing slash
$hadTrailingSlash = substr($path, -1) === '/';
// Replace multiple slashes with a single slash
$path = preg_replace('#/+#', '/', $path);
// Remove any "." segments
$path = str_replace('/./', '/', $path);
// Remove any ".." segments and the directory above them
while (strpos($path, '..') !== false) {
$path = preg_replace('#/[^/]+/\.\./#', '/', $path);
}
// Restore trailing slash if it was there
if ($hadTrailingSlash && substr($path, -1) !== '/') {
$path .= '/';
}
return $path;
}
// Run the main handler
handleRequest();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment