Files

106 lines
4.5 KiB
PHP
Raw Permalink Normal View History

<?php
declare(strict_types=1);
namespace App\Domain\Attachment;
/** Validates upload metadata without reading, storing, or moving the file. */
final class AttachmentValidator
{
/** @var array<string, list<string>> */
private const MIME_BY_EXTENSION = [
'jpg' => ['image/jpeg'],
'jpeg' => ['image/jpeg'],
'png' => ['image/png'],
'gif' => ['image/gif'],
'pdf' => ['application/pdf'],
'txt' => ['text/plain'],
'csv' => ['text/csv', 'application/csv'],
'doc' => ['application/msword'],
'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
'xls' => ['application/vnd.ms-excel'],
'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
];
public function __construct(private readonly int $maxSizeBytes = 10_000_000)
{
if ($maxSizeBytes < 1) {
throw new \InvalidArgumentException('Maximum attachment size must be positive.');
}
}
/** @return array<string, mixed> */
public function normalize(array $payload): array
{
$name = is_scalar($payload['name'] ?? $payload['original_name'] ?? null)
? trim((string) ($payload['name'] ?? $payload['original_name'])) : '';
$mime = is_scalar($payload['mime_type'] ?? $payload['mime'] ?? null)
? strtolower(trim((string) ($payload['mime_type'] ?? $payload['mime']))) : '';
$size = $this->normalizeSize($payload['size_bytes'] ?? $payload['size'] ?? null);
$extension = strtolower((string) pathinfo($name, PATHINFO_EXTENSION));
return [
'name' => $name,
'extension' => $extension,
'mime_type' => $mime,
'size_bytes' => $size,
'client_visible' => $this->normalizeBoolean($payload['client_visible'] ?? false),
'client_approved' => $this->normalizeBoolean($payload['client_approved'] ?? $payload['approved'] ?? false),
];
}
/** @return array<string, mixed> */
public function validate(array $payload): array
{
$normalized = $this->normalize($payload);
$errors = [];
$name = $normalized['name'];
$extension = $normalized['extension'];
$mime = $normalized['mime_type'];
if ($name === '' || mb_strlen($name) > 255 || str_contains($name, '/') || str_contains($name, '\\') || preg_match('/[\x00-\x1F\x7F]/', $name) === 1 || $name[0] === '.') {
$errors['name'] = 'Attachment name must be a safe file name of 255 characters or fewer.';
}
if ($extension === '' || !isset(self::MIME_BY_EXTENSION[$extension]) || preg_match('/(?:^|\.)php(?:\.|$)/i', $name) === 1) {
$errors['extension'] = 'Attachment extension is not allowed.';
}
if ($mime === '' || !in_array($mime, self::MIME_BY_EXTENSION[$extension] ?? [], true)) {
$errors['mime_type'] = 'Attachment MIME type does not match the allowed extension.';
}
if (!is_int($normalized['size_bytes']) || $normalized['size_bytes'] < 0 || $normalized['size_bytes'] > $this->maxSizeBytes) {
$errors['size_bytes'] = 'Attachment size must be between 0 and the configured maximum.';
}
foreach (['client_visible', 'client_approved'] as $field) {
if (!is_bool($normalized[$field])) {
$errors[$field] = 'Attachment approval flags must be boolean.';
}
}
if ($normalized['client_visible'] === true && $normalized['client_approved'] !== true) {
$errors['client_approved'] = 'Client-visible attachments require explicit client approval.';
}
return [...$normalized, 'valid' => $errors === [], 'errors' => $errors];
}
private function normalizeSize(mixed $value): mixed
{
if (is_int($value)) return $value;
if (is_string($value) && preg_match('/^\d+$/', trim($value)) === 1) {
$integer = filter_var(trim($value), FILTER_VALIDATE_INT);
return $integer === false ? $value : $integer;
}
return $value;
}
private function normalizeBoolean(mixed $value): mixed
{
if (is_bool($value)) return $value;
if (is_int($value) && ($value === 0 || $value === 1)) return $value === 1;
if (is_string($value)) return match (strtolower(trim($value))) {
'1', 'true', 'yes', 'on' => true,
'0', 'false', 'no', 'off' => false,
default => $value,
};
return $value;
}
}