* * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Component\Console\Helper; use Symfony\Component\Console\Exception\InvalidFileException; use Symfony\Component\Console\Exception\MissingInputException; use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Input\File\InputFile; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\FileQuestion; use Symfony\Component\Console\Terminal; use Symfony\Component\Console\Terminal\Image\ImageProtocolInterface; use Symfony\Component\Console\Terminal\Image\ITerm2Protocol; use Symfony\Component\Console\Terminal\Image\KittyGraphicsProtocol; /** * Orchestrates file input handling through paste detection or path input. * * @author Robin Chalas * * @internal */ final class FileInputHelper { private const BPM_ENABLE = "\x1b[?2004h"; private const BPM_DISABLE = "\x1b[?2004l"; private const PASTE_START = "\x1b[200~"; private const PASTE_END = "\x1b[201~"; private const MAX_PASTE_BYTES = 16 * 1024 * 1024; private ?ImageProtocolInterface $protocol = null; /** * @param resource $inputStream */ public function readFileInput($inputStream, OutputInterface $output, FileQuestion $question): InputFile { if ($canPaste = $question->isPasteAllowed() && Terminal::supportsImageProtocol() && Terminal::hasSttyAvailable()) { $this->protocol = $this->detectProtocol(); } $file = null; $inputHelper = null; try { if ($canPaste) { $inputHelper = new TerminalInputHelper($inputStream); $output->write(self::BPM_ENABLE); shell_exec('stty -icanon -echo'); $file = $this->readWithPasteDetection($inputStream, $output, $question, $inputHelper); } elseif ($question->isPathAllowed()) { $file = $this->readPathInput($inputStream); } else { throw new MissingInputException('Terminal does not support image paste and path input is disabled.'); } } finally { if ($canPaste) { $output->write(self::BPM_DISABLE); $inputHelper?->finish(); } } if (!$file->isValid()) { throw new InvalidFileException(\sprintf('File "%s" is not valid or readable.', $file->getPathname())); } $this->displayFile($output, $file); return $file; } public function displayFile(OutputInterface $output, InputFile $file): void { $link = \sprintf('%s', OutputFormatter::escape($file->getRealPath()), OutputFormatter::escape($file->getFilename())); if ($output->isVeryVerbose()) { $output->writeln(\sprintf('%s %s (%s, %s)', "\u{1F4CE}", $link, OutputFormatter::escape($file->getMimeType() ?? 'unknown'), $file->getHumanReadableSize())); } else { $output->writeln(\sprintf('%s %s', "\u{1F4CE}", $link)); } if (Terminal::supportsImageProtocol() && $this->isDisplayableImage($file)) { $this->displayThumbnail($output, $file); } } /** * @param resource $inputStream */ private function readWithPasteDetection($inputStream, OutputInterface $output, FileQuestion $question, TerminalInputHelper $inputHelper): InputFile { $buffer = ''; $inPaste = false; $pasteBuffer = ''; while (!feof($inputStream)) { $inputHelper->waitForInput(); $char = fread($inputStream, 1); if (false === $char || '' === $char) { if ('' === $buffer && '' === $pasteBuffer) { throw new MissingInputException('Aborted.'); } break; } $buffer .= $char; if (\strlen($buffer) > self::MAX_PASTE_BYTES) { throw new InvalidFileException(\sprintf('Pasted input exceeds the maximum allowed size of %d bytes.', self::MAX_PASTE_BYTES)); } if (!$inPaste && str_ends_with($buffer, self::PASTE_START)) { $inPaste = true; $buffer = substr($buffer, 0, -\strlen(self::PASTE_START)); continue; } if ($inPaste && str_ends_with($buffer, self::PASTE_END)) { $pasteBuffer = substr($buffer, 0, -\strlen(self::PASTE_END)); break; } if (!$inPaste && ("\n" === $char || "\r" === $char)) { $buffer = rtrim($buffer, "\r\n"); break; } } if ('' !== $pasteBuffer) { if (null !== $this->protocol && $this->protocol->detectPastedImage($pasteBuffer)) { $decoded = $this->protocol->decode($pasteBuffer); if ('' !== $decoded['data']) { return InputFile::fromData($decoded['data'], $decoded['format']); } } $path = trim($pasteBuffer); if ('' !== $path && $question->isPathAllowed()) { return InputFile::fromPath($path); } } $path = trim($buffer); if ('' !== $path && $question->isPathAllowed()) { return InputFile::fromPath($path); } throw new MissingInputException('No file input provided.'); } /** * @param resource $inputStream */ private function readPathInput($inputStream): InputFile { if (!$isBlocked = stream_get_meta_data($inputStream)['blocked'] ?? true) { stream_set_blocking($inputStream, true); } $path = fgets($inputStream); if (!$isBlocked) { stream_set_blocking($inputStream, false); } if (false === $path) { throw new MissingInputException('Aborted.'); } if ('' === $path = trim($path)) { throw new MissingInputException('No file path provided.'); } return InputFile::fromPath($path); } private function detectProtocol(): ?ImageProtocolInterface { if (Terminal::supportsKittyGraphics()) { return new KittyGraphicsProtocol(); } if (Terminal::supportsITerm2Images()) { return new ITerm2Protocol(); } return null; } private function isDisplayableImage(InputFile $file): bool { if (null === $mimeType = $file->getMimeType()) { return false; } return str_starts_with($mimeType, 'image/'); } private function displayThumbnail(OutputInterface $output, InputFile $file): void { try { $contents = $file->getContents(); } catch (InvalidFileException) { return; } $protocol = Terminal::supportsKittyGraphics() ? new KittyGraphicsProtocol() : new ITerm2Protocol(); $output->write($protocol->encode($contents, 16)); $output->writeln(''); } }