[TASK] Use null coalescing operator where possible
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Classes / Cache / Backend / FileBackend.php
1 <?php
2 namespace TYPO3\CMS\Core\Cache\Backend;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use TYPO3\CMS\Core\Service\OpcodeCacheService;
18 use TYPO3\CMS\Core\Utility\GeneralUtility;
19 use TYPO3\CMS\Core\Utility\StringUtility;
20
21 /**
22 * A caching backend which stores cache entries in files
23 *
24 * This file is a backport from FLOW3
25 * @api
26 */
27 class FileBackend extends \TYPO3\CMS\Core\Cache\Backend\SimpleFileBackend implements \TYPO3\CMS\Core\Cache\Backend\PhpCapableBackendInterface, \TYPO3\CMS\Core\Cache\Backend\FreezableBackendInterface, \TYPO3\CMS\Core\Cache\Backend\TaggableBackendInterface
28 {
29 const SEPARATOR = '^';
30 const EXPIRYTIME_FORMAT = 'YmdHis';
31 const EXPIRYTIME_LENGTH = 14;
32 const DATASIZE_DIGITS = 10;
33 /**
34 * A file extension to use for each cache entry.
35 *
36 * @var string
37 */
38 protected $cacheEntryFileExtension = '';
39
40 /**
41 * @var array
42 */
43 protected $cacheEntryIdentifiers = [];
44
45 /**
46 * @var bool
47 */
48 protected $frozen = false;
49
50 /**
51 * Freezes this cache backend.
52 *
53 * All data in a frozen backend remains unchanged and methods which try to add
54 * or modify data result in an exception thrown. Possible expiry times of
55 * individual cache entries are ignored.
56 *
57 * On the positive side, a frozen cache backend is much faster on read access.
58 * A frozen backend can only be thawed by calling the flush() method.
59 *
60 * @throws \RuntimeException
61 */
62 public function freeze()
63 {
64 if ($this->frozen === true) {
65 throw new \RuntimeException(sprintf('The cache "%s" is already frozen.', $this->cacheIdentifier), 1323353176);
66 }
67 $cacheEntryFileExtensionLength = strlen($this->cacheEntryFileExtension);
68 for ($directoryIterator = new \DirectoryIterator($this->cacheDirectory); $directoryIterator->valid(); $directoryIterator->next()) {
69 if ($directoryIterator->isDot()) {
70 continue;
71 }
72 if ($cacheEntryFileExtensionLength > 0) {
73 $entryIdentifier = substr($directoryIterator->getFilename(), 0, -$cacheEntryFileExtensionLength);
74 } else {
75 $entryIdentifier = $directoryIterator->getFilename();
76 }
77 $this->cacheEntryIdentifiers[$entryIdentifier] = true;
78 file_put_contents($this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension, $this->get($entryIdentifier));
79 }
80 file_put_contents($this->cacheDirectory . 'FrozenCache.data', serialize($this->cacheEntryIdentifiers));
81 $this->frozen = true;
82 }
83
84 /**
85 * Tells if this backend is frozen.
86 *
87 * @return bool
88 */
89 public function isFrozen()
90 {
91 return $this->frozen;
92 }
93
94 /**
95 * Sets a reference to the cache frontend which uses this backend and
96 * initializes the default cache directory.
97 *
98 * This method also detects if this backend is frozen and sets the internal
99 * flag accordingly.
100 *
101 * TYPO3 v4 note: This method is different between TYPO3 v4 and FLOW3
102 * because the Environment class to get the path to a temporary directory
103 * does not exist in v4.
104 *
105 * @param \TYPO3\CMS\Core\Cache\Frontend\FrontendInterface $cache The cache frontend
106 */
107 public function setCache(\TYPO3\CMS\Core\Cache\Frontend\FrontendInterface $cache)
108 {
109 parent::setCache($cache);
110 if (file_exists($this->cacheDirectory . 'FrozenCache.data')) {
111 $this->frozen = true;
112 $this->cacheEntryIdentifiers = unserialize(file_get_contents($this->cacheDirectory . 'FrozenCache.data'));
113 }
114 }
115
116 /**
117 * Saves data in a cache file.
118 *
119 * @param string $entryIdentifier An identifier for this specific cache entry
120 * @param string $data The data to be stored
121 * @param array $tags Tags to associate with this cache entry
122 * @param int $lifetime Lifetime of this cache entry in seconds. If NULL is specified, the default lifetime is used. "0" means unlimited lifetime.
123 * @throws \RuntimeException
124 * @throws \TYPO3\CMS\Core\Cache\Exception\InvalidDataException if the directory does not exist or is not writable or exceeds the maximum allowed path length, or if no cache frontend has been set.
125 * @throws \TYPO3\CMS\Core\Cache\Exception if the directory does not exist or is not writable or exceeds the maximum allowed path length, or if no cache frontend has been set.
126 * @throws \InvalidArgumentException
127 * @api
128 */
129 public function set($entryIdentifier, $data, array $tags = [], $lifetime = null)
130 {
131 if (!is_string($data)) {
132 throw new \TYPO3\CMS\Core\Cache\Exception\InvalidDataException('The specified data is of type "' . gettype($data) . '" but a string is expected.', 1204481674);
133 }
134 if ($entryIdentifier !== basename($entryIdentifier)) {
135 throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1282073032);
136 }
137 if ($entryIdentifier === '') {
138 throw new \InvalidArgumentException('The specified entry identifier must not be empty.', 1298114280);
139 }
140 if ($this->frozen === true) {
141 throw new \RuntimeException(sprintf('Cannot add or modify cache entry because the backend of cache "%s" is frozen.', $this->cacheIdentifier), 1323344192);
142 }
143 $this->remove($entryIdentifier);
144 $temporaryCacheEntryPathAndFilename = $this->cacheDirectory . StringUtility::getUniqueId() . '.temp';
145 $lifetime = $lifetime ?? $this->defaultLifetime;
146 $expiryTime = $lifetime === 0 ? 0 : $GLOBALS['EXEC_TIME'] + $lifetime;
147 $metaData = str_pad($expiryTime, self::EXPIRYTIME_LENGTH) . implode(' ', $tags) . str_pad(strlen($data), self::DATASIZE_DIGITS);
148 $result = file_put_contents($temporaryCacheEntryPathAndFilename, $data . $metaData);
149 \TYPO3\CMS\Core\Utility\GeneralUtility::fixPermissions($temporaryCacheEntryPathAndFilename);
150 if ($result === false) {
151 throw new \TYPO3\CMS\Core\Cache\Exception('The temporary cache file "' . $temporaryCacheEntryPathAndFilename . '" could not be written.', 1204026251);
152 }
153 $i = 0;
154 $cacheEntryPathAndFilename = $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension;
155 while (($result = rename($temporaryCacheEntryPathAndFilename, $cacheEntryPathAndFilename)) === false && $i < 5) {
156 $i++;
157 }
158 if ($result === false) {
159 throw new \TYPO3\CMS\Core\Cache\Exception('The cache file "' . $cacheEntryPathAndFilename . '" could not be written.', 1222361632);
160 }
161 if ($this->cacheEntryFileExtension === '.php') {
162 GeneralUtility::makeInstance(OpcodeCacheService::class)->clearAllActive($cacheEntryPathAndFilename);
163 }
164 }
165
166 /**
167 * Loads data from a cache file.
168 *
169 * @param string $entryIdentifier An identifier which describes the cache entry to load
170 * @return mixed The cache entry's content as a string or FALSE if the cache entry could not be loaded
171 * @throws \InvalidArgumentException If identifier is invalid
172 * @api
173 */
174 public function get($entryIdentifier)
175 {
176 if ($this->frozen === true) {
177 return isset($this->cacheEntryIdentifiers[$entryIdentifier]) ? file_get_contents($this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension) : false;
178 }
179 if ($entryIdentifier !== basename($entryIdentifier)) {
180 throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1282073033);
181 }
182 $pathAndFilename = $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension;
183 if ($this->isCacheFileExpired($pathAndFilename)) {
184 return false;
185 }
186 $dataSize = (int)file_get_contents($pathAndFilename, null, null, (filesize($pathAndFilename) - self::DATASIZE_DIGITS), self::DATASIZE_DIGITS);
187 return file_get_contents($pathAndFilename, null, null, 0, $dataSize);
188 }
189
190 /**
191 * Checks if a cache entry with the specified identifier exists.
192 *
193 * @param string $entryIdentifier
194 * @return bool TRUE if such an entry exists, FALSE if not
195 * @throws \InvalidArgumentException
196 * @api
197 */
198 public function has($entryIdentifier)
199 {
200 if ($this->frozen === true) {
201 return isset($this->cacheEntryIdentifiers[$entryIdentifier]);
202 }
203 if ($entryIdentifier !== basename($entryIdentifier)) {
204 throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1282073034);
205 }
206 return !$this->isCacheFileExpired(($this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension));
207 }
208
209 /**
210 * Removes all cache entries matching the specified identifier.
211 * Usually this only affects one entry.
212 *
213 * @param string $entryIdentifier Specifies the cache entry to remove
214 * @return bool TRUE if (at least) an entry could be removed or FALSE if no entry was found
215 * @throws \RuntimeException
216 * @throws \InvalidArgumentException
217 * @api
218 */
219 public function remove($entryIdentifier)
220 {
221 if ($entryIdentifier !== basename($entryIdentifier)) {
222 throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1282073035);
223 }
224 if ($entryIdentifier === '') {
225 throw new \InvalidArgumentException('The specified entry identifier must not be empty.', 1298114279);
226 }
227 if ($this->frozen === true) {
228 throw new \RuntimeException(sprintf('Cannot remove cache entry because the backend of cache "%s" is frozen.', $this->cacheIdentifier), 1323344193);
229 }
230 $pathAndFilename = $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension;
231 if (file_exists($pathAndFilename) === false) {
232 return false;
233 }
234 if (@unlink($pathAndFilename) === false) {
235 return false;
236 }
237 return true;
238 }
239
240 /**
241 * Finds and returns all cache entry identifiers which are tagged by the
242 * specified tag.
243 *
244 * @param string $searchedTag The tag to search for
245 * @return array An array with identifiers of all matching entries. An empty array if no entries matched
246 * @api
247 */
248 public function findIdentifiersByTag($searchedTag)
249 {
250 $entryIdentifiers = [];
251 $now = $GLOBALS['EXEC_TIME'];
252 $cacheEntryFileExtensionLength = strlen($this->cacheEntryFileExtension);
253 for ($directoryIterator = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\DirectoryIterator::class, $this->cacheDirectory); $directoryIterator->valid(); $directoryIterator->next()) {
254 if ($directoryIterator->isDot()) {
255 continue;
256 }
257 $cacheEntryPathAndFilename = $directoryIterator->getPathname();
258 $index = (int)file_get_contents($cacheEntryPathAndFilename, null, null, (filesize($cacheEntryPathAndFilename) - self::DATASIZE_DIGITS), self::DATASIZE_DIGITS);
259 $metaData = file_get_contents($cacheEntryPathAndFilename, null, null, $index);
260 $expiryTime = (int)substr($metaData, 0, self::EXPIRYTIME_LENGTH);
261 if ($expiryTime !== 0 && $expiryTime < $now) {
262 continue;
263 }
264 if (in_array($searchedTag, explode(' ', substr($metaData, self::EXPIRYTIME_LENGTH, -self::DATASIZE_DIGITS)))) {
265 if ($cacheEntryFileExtensionLength > 0) {
266 $entryIdentifiers[] = substr($directoryIterator->getFilename(), 0, -$cacheEntryFileExtensionLength);
267 } else {
268 $entryIdentifiers[] = $directoryIterator->getFilename();
269 }
270 }
271 }
272 return $entryIdentifiers;
273 }
274
275 /**
276 * Removes all cache entries of this cache and sets the frozen flag to FALSE.
277 *
278 * @api
279 */
280 public function flush()
281 {
282 parent::flush();
283 if ($this->frozen === true) {
284 $this->frozen = false;
285 }
286 }
287
288 /**
289 * Removes all cache entries of this cache which are tagged by the specified tag.
290 *
291 * @param string $tag The tag the entries must have
292 * @api
293 */
294 public function flushByTag($tag)
295 {
296 $identifiers = $this->findIdentifiersByTag($tag);
297 if (empty($identifiers)) {
298 return;
299 }
300 foreach ($identifiers as $entryIdentifier) {
301 $this->remove($entryIdentifier);
302 }
303 }
304
305 /**
306 * Checks if the given cache entry files are still valid or if their
307 * lifetime has exceeded.
308 *
309 * @param string $cacheEntryPathAndFilename
310 * @return bool
311 * @api
312 */
313 protected function isCacheFileExpired($cacheEntryPathAndFilename)
314 {
315 if (file_exists($cacheEntryPathAndFilename) === false) {
316 return true;
317 }
318 $index = (int)file_get_contents($cacheEntryPathAndFilename, null, null, (filesize($cacheEntryPathAndFilename) - self::DATASIZE_DIGITS), self::DATASIZE_DIGITS);
319 $expiryTime = (int)file_get_contents($cacheEntryPathAndFilename, null, null, $index, self::EXPIRYTIME_LENGTH);
320 return $expiryTime !== 0 && $expiryTime < $GLOBALS['EXEC_TIME'];
321 }
322
323 /**
324 * Does garbage collection
325 *
326 * @api
327 */
328 public function collectGarbage()
329 {
330 if ($this->frozen === true) {
331 return;
332 }
333 for ($directoryIterator = new \DirectoryIterator($this->cacheDirectory); $directoryIterator->valid(); $directoryIterator->next()) {
334 if ($directoryIterator->isDot()) {
335 continue;
336 }
337 if ($this->isCacheFileExpired($directoryIterator->getPathname())) {
338 $cacheEntryFileExtensionLength = strlen($this->cacheEntryFileExtension);
339 if ($cacheEntryFileExtensionLength > 0) {
340 $this->remove(substr($directoryIterator->getFilename(), 0, -$cacheEntryFileExtensionLength));
341 } else {
342 $this->remove($directoryIterator->getFilename());
343 }
344 }
345 }
346 }
347
348 /**
349 * Tries to find the cache entry for the specified identifier.
350 * Usually only one cache entry should be found - if more than one exist, this
351 * is due to some error or crash.
352 *
353 * @param string $entryIdentifier The cache entry identifier
354 * @return mixed The filenames (including path) as an array if one or more entries could be found, otherwise FALSE
355 */
356 protected function findCacheFilesByIdentifier($entryIdentifier)
357 {
358 $pattern = $this->cacheDirectory . $entryIdentifier;
359 $filesFound = glob($pattern);
360 if ($filesFound === false || empty($filesFound)) {
361 return false;
362 }
363 return $filesFound;
364 }
365
366 /**
367 * Loads PHP code from the cache and require_onces it right away.
368 *
369 * @param string $entryIdentifier An identifier which describes the cache entry to load
370 * @throws \InvalidArgumentException
371 * @return mixed Potential return value from the include operation
372 * @api
373 */
374 public function requireOnce($entryIdentifier)
375 {
376 if ($this->frozen === true) {
377 if (isset($this->cacheEntryIdentifiers[$entryIdentifier])) {
378 return require_once $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension;
379 }
380 return false;
381 }
382 if ($entryIdentifier !== basename($entryIdentifier)) {
383 throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1282073036);
384 }
385 $pathAndFilename = $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension;
386 return $this->isCacheFileExpired($pathAndFilename) ? false : require_once $pathAndFilename;
387 }
388 }