> */ 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 */ 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 */ 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; } }