$params */ public static function fromParams(array $params): self { return new self( randomName: self::toBool($params['random_name'] ?? false), avoidOverwriting: self::toBool($params['avoid_overwriting'] ?? false), accept: self::toAcceptList($params['accept'] ?? null), filesizeMb: self::toFilesize($params['filesize'] ?? null), ); } /** * Whether any field-level setting is active. */ public function isEmpty(): bool { return !$this->randomName && !$this->avoidOverwriting && $this->accept === [] && $this->filesizeMb === null; } /** * Enforce the per-field maximum filesize (MB). The endpoint's own hard cap * is applied separately and always wins; this only tightens it. */ public function assertFilesize(?int $size): void { if ($this->filesizeMb === null || $this->filesizeMb <= 0 || $size === null) { return; } $max = (int) round($this->filesizeMb * 1_048_576); if ($size > $max) { $label = rtrim(rtrim(number_format($this->filesizeMb, 2), '0'), '.'); throw new ValidationException( sprintf('File exceeds the maximum allowed size of %s MB for this field.', $label) ); } } /** * Enforce the field's `accept` allowlist (mime types such as `image/*`, or * extensions such as `.pdf`). Mirrors the form plugin's matching, including * deriving the mime from the filename rather than trusting the browser. */ public function assertAccepted(string $filename): void { if ($this->accept === []) { return; } $mime = Utils::getMimeByFilename($filename); foreach ($this->accept as $type) { if ($type === '') { continue; } if ($type === '*') { return; } $isMime = str_contains($type, '/'); $pattern = '#' . str_replace(['.', '*', '+'], ['\.', '.*', '\+'], $type) . '$#'; $subject = $isMime ? $mime : $filename; if (preg_match($pattern, $subject)) { return; } } throw new ValidationException( sprintf("File '%s' does not match the accepted types for this field.", $filename) ); } /** * Decide the final stored filename, applying `random_name` then * `avoid_overwriting` against the resolved target directory. * * Both transforms preserve the file extension (random names re-append it; * the conflict guard only prepends a datetime), so a caller that already * validated the extension on the incoming name does not need to re-check. */ public function resolveFilename(string $filename, string $targetDir): string { if ($this->randomName) { $extension = pathinfo($filename, PATHINFO_EXTENSION); $random = Utils::generateRandomString(15); $filename = strtolower($extension !== '' ? "{$random}.{$extension}" : $random); } if ($this->avoidOverwriting && is_file($targetDir . '/' . $filename)) { $filename = date('YmdHis') . '-' . $filename; } return $filename; } private static function toBool(mixed $value): bool { if (is_bool($value)) { return $value; } if (is_string($value)) { return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); } return (bool) $value; } /** * Accept may arrive as an array (JSON body) or a comma-separated string * (multipart meta). Normalize to a trimmed list of non-empty entries. * * @return string[] */ private static function toAcceptList(mixed $value): array { if (is_string($value)) { $value = $value === '' ? [] : explode(',', $value); } if (!is_array($value)) { return []; } $out = []; foreach ($value as $item) { $item = trim((string) $item); if ($item !== '') { $out[] = $item; } } return $out; } private static function toFilesize(mixed $value): ?float { if ($value === null || $value === '' || !is_numeric($value)) { return null; } $filesize = (float) $value; return $filesize > 0 ? $filesize : null; } }