Level: intermediate. You will build a small file manager: users upload files, see a list, download them and delete them. The focus is security. File handling is where careless code gets servers hacked.
The rules (from the upload lessons)
- Allow only specific types, checked by content (
finfo), not by name. - Limit the size.
- Store files under a random name, outside the web root.
- Keep the original name only as data in the database, and escape it when you show it.
- Serve downloads through PHP, with safe headers.
- Delete with POST and a CSRF token.
The table
CREATE TABLE uploads (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
original_name VARCHAR(255) NOT NULL,
stored_name CHAR(36) NOT NULL UNIQUE,
mime VARCHAR(100) NOT NULL,
size INT UNSIGNED NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
The upload manager
<?php
declare(strict_types=1);
final class UploadException extends RuntimeException {}
final class UploadManager
{
/** allowed MIME type => the extension WE use */
private const ALLOWED = [
"image/png" => "png",
"image/jpeg" => "jpg",
"image/webp" => "webp",
"application/pdf" => "pdf",
];
/**
* @param callable(string, string): bool $mover moves the temp file; move_uploaded_file in real use
*/
public function __construct(
private PDO $pdo,
private string $folder,
private int $maxBytes = 2_000_000,
private $mover = "move_uploaded_file",
) {}
/** @param array{name?: string, tmp_name?: string, error?: int, size?: int} $file one entry of $_FILES */
public function handle(array $file): int
{
$error = $file["error"] ?? UPLOAD_ERR_NO_FILE;
if ($error !== UPLOAD_ERR_OK) {
throw new UploadException(match ($error) {
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => "The file is too large.",
UPLOAD_ERR_NO_FILE => "Please choose a file.",
default => "The upload failed. Please try again.",
});
}
$tmp = (string) ($file["tmp_name"] ?? "");
$size = (int) ($file["size"] ?? 0);
if ($size < 1 || $size > $this->maxBytes) {
throw new UploadException("The file must be between 1 byte and " . number_format($this->maxBytes / 1_000_000, 1) . " MB.");
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($tmp);
if (!is_string($mime) || !isset(self::ALLOWED[$mime])) {
throw new UploadException("Only PNG, JPEG, WebP and PDF files are allowed.");
}
if (str_starts_with($mime, "image/") && getimagesize($tmp) === false) {
throw new UploadException("The image is not valid.");
}
if (!is_dir($this->folder)) {
mkdir($this->folder, 0755, true);
}
$stored = bin2hex(random_bytes(16)) . "." . self::ALLOWED[$mime];
$target = $this->folder . "/" . $stored;
if (!($this->mover)($tmp, $target)) {
throw new UploadException("The file could not be saved.");
}
$stmt = $this->pdo->prepare(
"INSERT INTO uploads (original_name, stored_name, mime, size, created_at) VALUES (:name, :stored, :mime, :size, :created)"
);
$stmt->execute([
"name" => $this->cleanName((string) ($file["name"] ?? "file")),
"stored" => $stored,
"mime" => $mime,
"size" => $size,
"created" => date("Y-m-d H:i:s"),
]);
return (int) $this->pdo->lastInsertId();
}
public function all(): array
{
return $this->pdo->query("SELECT id, original_name, mime, size FROM uploads ORDER BY id DESC")->fetchAll();
}
public function find(int $id): ?array
{
$stmt = $this->pdo->prepare("SELECT id, original_name, stored_name, mime, size FROM uploads WHERE id = :id");
$stmt->execute(["id" => $id]);
return $stmt->fetch() ?: null;
}
public function path(array $row): string
{
return $this->folder . "/" . basename($row["stored_name"]);
}
public function delete(int $id): bool
{
$row = $this->find($id);
if ($row === null) {
return false;
}
$this->pdo->prepare("DELETE FROM uploads WHERE id = :id")->execute(["id" => $id]);
$path = $this->path($row);
if (is_file($path)) {
unlink($path);
}
return true;
}
private function cleanName(string $name): string
{
$name = basename(str_replace("\\", "/", $name));
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', "_", $name) ?? "file";
return mb_substr(trim($name), 0, 150) ?: "file";
}
}
Notice $mover. In production it is move_uploaded_file, which works only for real HTTP uploads. In a test we pass copy, so we can try the whole class from the command line.
Test it with real files
<?php
require __DIR__ . "/UploadManager.php";
$pdo = require __DIR__ . "/db.php";
$manager = new UploadManager($pdo, __DIR__ . "/storage/uploads", 50_000, "copy");
$png = base64_decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==");
file_put_contents("photo.tmp", $png);
file_put_contents("evil.tmp", "<?php echo 'gotcha';");
file_put_contents("big.tmp", str_repeat("x", 60000));
$attempts = [
"a real image" => ["name" => "../../holiday photo.png", "tmp_name" => "photo.tmp", "error" => 0, "size" => strlen($png)],
"a script named .jpg" => ["name" => "pic.jpg", "tmp_name" => "evil.tmp", "error" => 0, "size" => 20],
"too large" => ["name" => "big.png", "tmp_name" => "big.tmp", "error" => 0, "size" => 60000],
"no file chosen" => ["name" => "", "tmp_name" => "", "error" => UPLOAD_ERR_NO_FILE, "size" => 0],
];
foreach ($attempts as $label => $file) {
try {
$id = $manager->handle($file);
echo "$label: saved as #$id\n";
} catch (UploadException $e) {
echo "$label: " . $e->getMessage() . "\n";
}
}
$row = $manager->find(1);
echo $row["original_name"] . " / " . $row["mime"] . "\n";
echo preg_match('/^[a-f0-9]{32}\.png$/', $row["stored_name"]) === 1 ? "stored under a random name\n" : "unexpected name\n";
echo is_file($manager->path($row)) ? "file exists on disk\n" : "missing\n";
$manager->delete(1);
echo is_file($manager->path($row)) ? "still on disk" : "removed from disk and database";
a real image: saved as #1
a script named .jpg: Only PNG, JPEG, WebP and PDF files are allowed.
too large: The file must be between 1 byte and 0.1 MB.
no file chosen: Please choose a file.
holiday photo.png / image/png
stored under a random name
file exists on disk
removed from disk and database
Every attack was stopped: the PHP script pretending to be a JPEG, the oversized file and the missing file. The path-trick name ../../holiday photo.png was reduced to holiday photo.png.
The pages
<?php
declare(strict_types=1);
session_start();
require __DIR__ . "/functions.php";
require __DIR__ . "/UploadManager.php";
$manager = new UploadManager(require __DIR__ . "/db.php", dirname(__DIR__) . "/storage/uploads");
$error = null;
if ($_SERVER["REQUEST_METHOD"] === "POST") {
csrfCheck();
try {
$manager->handle($_FILES["file"] ?? []);
$_SESSION["flash"] = "File uploaded.";
header("Location: index.php");
exit;
} catch (UploadException $e) {
$error = $e->getMessage();
}
}
$flash = $_SESSION["flash"] ?? null;
unset($_SESSION["flash"]);
?>
<h1>Files</h1>
<?php if ($flash): ?><p role="status"><?= h($flash) ?></p><?php endif; ?>
<?php if ($error): ?><p role="alert"><?= h($error) ?></p><?php endif; ?>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= h(csrfToken()) ?>">
<label for="file">Choose a file (PNG, JPEG, WebP or PDF, up to 2 MB)</label>
<input type="file" id="file" name="file" accept=".png,.jpg,.jpeg,.webp,.pdf" required>
<button type="submit">Upload</button>
</form>
<ul>
<?php foreach ($manager->all() as $f): ?>
<li>
<a href="download.php?id=<?= (int) $f["id"] ?>"><?= h($f["original_name"]) ?></a>
(<?= h($f["mime"]) ?>, <?= number_format($f["size"] / 1024, 1) ?> KB)
<form method="post" action="delete.php" style="display:inline">
<input type="hidden" name="csrf_token" value="<?= h(csrfToken()) ?>">
<input type="hidden" name="id" value="<?= (int) $f["id"] ?>">
<button>Delete</button>
</form>
</li>
<?php endforeach; ?>
</ul>
<?php
declare(strict_types=1);
require __DIR__ . "/UploadManager.php";
$manager = new UploadManager(require __DIR__ . "/db.php", dirname(__DIR__) . "/storage/uploads");
$id = filter_var($_GET["id"] ?? null, FILTER_VALIDATE_INT);
$row = $id === false ? null : $manager->find($id);
if ($row === null || !is_file($manager->path($row))) {
http_response_code(404);
exit("File not found.");
}
$downloadName = str_replace(['"', "\r", "\n"], "", $row["original_name"]);
header("Content-Type: " . $row["mime"]);
header("Content-Length: " . $row["size"]);
header("X-Content-Type-Options: nosniff");
header('Content-Disposition: attachment; filename="' . $downloadName . '"');
readfile($manager->path($row));
attachment makes the browser download instead of displaying the file. nosniff stops it from guessing a different type. The path always comes from the database row (a name that we generated), never from the request.
What you practised
- Secure uploads:
finfo, allow-list, size limit, random name, storage outside the web root. - Dependency injection of the mover, so the class can be tested.
- Serving downloads with safe headers.
- Keeping the database row and the file on disk in step.
Challenges
- Add a per-user file list and permissions (Protecting Pages).
- Create thumbnails of images with GD, and re-encode them to remove hidden data.
- Add a storage quota per user.
- Scan uploads with ClamAV if your server has it.