106 lines
4.5 KiB
PHP
106 lines
4.5 KiB
PHP
<?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;
|
||
|
|
}
|
||
|
|
}
|