1<?php declare(strict_types=1); 2 3/* 4 * This file is part of the Monolog package. 5 * 6 * (c) Jordi Boggiano <j.boggiano@seld.be> 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 */ 11 12namespace Monolog\Handler; 13 14use Monolog\Logger; 15use Monolog\Utils; 16 17/** 18 * Stores to any stream resource 19 * 20 * Can be used to store into php://stderr, remote and local files, etc. 21 * 22 * @author Jordi Boggiano <j.boggiano@seld.be> 23 * 24 * @phpstan-import-type FormattedRecord from AbstractProcessingHandler 25 */ 26class StreamHandler extends AbstractProcessingHandler 27{ 28 /** @const int */ 29 protected const MAX_CHUNK_SIZE = 2147483647; 30 /** @const int 10MB */ 31 protected const DEFAULT_CHUNK_SIZE = 10 * 1024 * 1024; 32 /** @var int */ 33 protected $streamChunkSize; 34 /** @var resource|null */ 35 protected $stream; 36 /** @var ?string */ 37 protected $url = null; 38 /** @var ?string */ 39 private $errorMessage = null; 40 /** @var ?int */ 41 protected $filePermission; 42 /** @var bool */ 43 protected $useLocking; 44 /** @var true|null */ 45 private $dirCreated = null; 46 47 /** 48 * @param resource|string $stream If a missing path can't be created, an UnexpectedValueException will be thrown on first write 49 * @param int|null $filePermission Optional file permissions (default (0644) are only for owner read/write) 50 * @param bool $useLocking Try to lock log file before doing any writes 51 * 52 * @throws \InvalidArgumentException If stream is not a resource or string 53 */ 54 public function __construct($stream, $level = Logger::DEBUG, bool $bubble = true, ?int $filePermission = null, bool $useLocking = false) 55 { 56 parent::__construct($level, $bubble); 57 58 if (($phpMemoryLimit = Utils::expandIniShorthandBytes(ini_get('memory_limit'))) !== false) { 59 if ($phpMemoryLimit > 0) { 60 // use max 10% of allowed memory for the chunk size, and at least 100KB 61 $this->streamChunkSize = min(static::MAX_CHUNK_SIZE, max((int) ($phpMemoryLimit / 10), 100 * 1024)); 62 } else { 63 // memory is unlimited, set to the default 10MB 64 $this->streamChunkSize = static::DEFAULT_CHUNK_SIZE; 65 } 66 } else { 67 // no memory limit information, set to the default 10MB 68 $this->streamChunkSize = static::DEFAULT_CHUNK_SIZE; 69 } 70 71 if (is_resource($stream)) { 72 $this->stream = $stream; 73 74 stream_set_chunk_size($this->stream, $this->streamChunkSize); 75 } elseif (is_string($stream)) { 76 $this->url = Utils::canonicalizePath($stream); 77 } else { 78 throw new \InvalidArgumentException('A stream must either be a resource or a string.'); 79 } 80 81 $this->filePermission = $filePermission; 82 $this->useLocking = $useLocking; 83 } 84 85 /** 86 * {@inheritDoc} 87 */ 88 public function close(): void 89 { 90 if ($this->url && is_resource($this->stream)) { 91 fclose($this->stream); 92 } 93 $this->stream = null; 94 $this->dirCreated = null; 95 } 96 97 /** 98 * Return the currently active stream if it is open 99 * 100 * @return resource|null 101 */ 102 public function getStream() 103 { 104 return $this->stream; 105 } 106 107 /** 108 * Return the stream URL if it was configured with a URL and not an active resource 109 * 110 * @return string|null 111 */ 112 public function getUrl(): ?string 113 { 114 return $this->url; 115 } 116 117 /** 118 * @return int 119 */ 120 public function getStreamChunkSize(): int 121 { 122 return $this->streamChunkSize; 123 } 124 125 /** 126 * {@inheritDoc} 127 */ 128 protected function write(array $record): void 129 { 130 if (!is_resource($this->stream)) { 131 $url = $this->url; 132 if (null === $url || '' === $url) { 133 throw new \LogicException('Missing stream url, the stream can not be opened. This may be caused by a premature call to close().' . Utils::getRecordMessageForException($record)); 134 } 135 $this->createDir($url); 136 $this->errorMessage = null; 137 set_error_handler([$this, 'customErrorHandler']); 138 $stream = fopen($url, 'a'); 139 if ($this->filePermission !== null) { 140 @chmod($url, $this->filePermission); 141 } 142 restore_error_handler(); 143 if (!is_resource($stream)) { 144 $this->stream = null; 145 146 throw new \UnexpectedValueException(sprintf('The stream or file "%s" could not be opened in append mode: '.$this->errorMessage, $url) . Utils::getRecordMessageForException($record)); 147 } 148 stream_set_chunk_size($stream, $this->streamChunkSize); 149 $this->stream = $stream; 150 } 151 152 $stream = $this->stream; 153 if (!is_resource($stream)) { 154 throw new \LogicException('No stream was opened yet' . Utils::getRecordMessageForException($record)); 155 } 156 157 if ($this->useLocking) { 158 // ignoring errors here, there's not much we can do about them 159 flock($stream, LOCK_EX); 160 } 161 162 $this->streamWrite($stream, $record); 163 164 if ($this->useLocking) { 165 flock($stream, LOCK_UN); 166 } 167 } 168 169 /** 170 * Write to stream 171 * @param resource $stream 172 * @param array $record 173 * 174 * @phpstan-param FormattedRecord $record 175 */ 176 protected function streamWrite($stream, array $record): void 177 { 178 fwrite($stream, (string) $record['formatted']); 179 } 180 181 private function customErrorHandler(int $code, string $msg): bool 182 { 183 $this->errorMessage = preg_replace('{^(fopen|mkdir)\(.*?\): }', '', $msg); 184 185 return true; 186 } 187 188 private function getDirFromStream(string $stream): ?string 189 { 190 $pos = strpos($stream, '://'); 191 if ($pos === false) { 192 return dirname($stream); 193 } 194 195 if ('file://' === substr($stream, 0, 7)) { 196 return dirname(substr($stream, 7)); 197 } 198 199 return null; 200 } 201 202 private function createDir(string $url): void 203 { 204 // Do not try to create dir if it has already been tried. 205 if ($this->dirCreated) { 206 return; 207 } 208 209 $dir = $this->getDirFromStream($url); 210 if (null !== $dir && !is_dir($dir)) { 211 $this->errorMessage = null; 212 set_error_handler([$this, 'customErrorHandler']); 213 $status = mkdir($dir, 0777, true); 214 restore_error_handler(); 215 if (false === $status && !is_dir($dir)) { 216 throw new \UnexpectedValueException(sprintf('There is no existing directory at "%s" and it could not be created: '.$this->errorMessage, $dir)); 217 } 218 } 219 $this->dirCreated = true; 220 } 221} 222