3118a1395cdf1b2ed717f7710db9ddfe0b57c57e
[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\PathUtility;
20 use TYPO3\CMS\Core\Utility\StringUtility;
21
22 /**
23 * A caching backend which stores cache entries in files
24 *
25 * @api
26 */
27 class FileBackend extends \TYPO3\CMS\Core\Cache\Backend\SimpleFileBackend implements \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 * @param \TYPO3\CMS\Core\Cache\Frontend\FrontendInterface $cache The cache frontend
102 */
103 public function setCache(\TYPO3\CMS\Core\Cache\Frontend\FrontendInterface $cache)
104 {
105 parent::setCache($cache);
106 if (file_exists($this->cacheDirectory . 'FrozenCache.data')) {
107 $this->frozen = true;
108 $this->cacheEntryIdentifiers = unserialize(file_get_contents($this->cacheDirectory . 'FrozenCache.data'));
109 }
110 }
111
112 /**
113 * Saves data in a cache file.
114 *
115 * @param string $entryIdentifier An identifier for this specific cache entry
116 * @param string $data The data to be stored
117 * @param array $tags Tags to associate with this cache entry
118 * @param int $lifetime Lifetime of this cache entry in seconds. If NULL is specified, the default lifetime is used. "0" means unlimited lifetime.
119 * @throws \RuntimeException
120 * @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.
121 * @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.
122 * @throws \InvalidArgumentException
123 * @api
124 */
125 public function set($entryIdentifier, $data, array $tags = [], $lifetime = null)
126 {
127 if (!is_string($data)) {
128 throw new \TYPO3\CMS\Core\Cache\Exception\InvalidDataException('The specified data is of type "' . gettype($data) . '" but a string is expected.', 1204481674);
129 }
130 if ($entryIdentifier !== PathUtility::basename($entryIdentifier)) {
131 throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1282073032);
132 }
133 if ($entryIdentifier === '') {
134 throw new \InvalidArgumentException('The specified entry identifier must not be empty.', 1298114280);
135 }
136 if ($this->frozen === true) {
137 throw new \RuntimeException(sprintf('Cannot add or modify cache entry because the backend of cache "%s" is frozen.', $this->cacheIdentifier), 1323344192);
138 }
139 $this->remove($entryIdentifier);
140 $temporaryCacheEntryPathAndFilename = $this->cacheDirectory . StringUtility::getUniqueId() . '.temp';
141 $lifetime = $lifetime ?? $this->defaultLifetime;
142 $expiryTime = $lifetime === 0 ? 0 : $GLOBALS['EXEC_TIME'] + $lifetime;
143 $metaData = str_pad($expiryTime, self::EXPIRYTIME_LENGTH) . implode(' ', $tags) . str_pad(strlen($data), self::DATASIZE_DIGITS);
144 $result = file_put_contents($temporaryCacheEntryPathAndFilename, $data . $metaData);
145 \TYPO3\CMS\Core\Utility\GeneralUtility::fixPermissions($temporaryCacheEntryPathAndFilename);
146 if ($result === false) {
147 throw new \TYPO3\CMS\Core\Cache\Exception('The temporary cache file "' . $temporaryCacheEntryPathAndFilename . '" could not be written.', 1204026251);
148 }
149 $i = 0;
150 $cacheEntryPathAndFilename = $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension;
151 while (($result = rename($temporaryCacheEntryPathAndFilename, $cacheEntryPathAndFilename)) === false && $i < 5) {
152 $i++;
153 }
154 if ($result === false) {
155 throw new \TYPO3\CMS\Core\Cache\Exception('The cache file "' . $cacheEntryPathAndFilename . '" could not be written.', 1222361632);
156 }
157 if ($this->cacheEntryFileExtension === '.php') {
158 GeneralUtility::makeInstance(OpcodeCacheService::class)->clearAllActive($cacheEntryPathAndFilename);
159 }
160 }
161
162 /**
163 * Loads data from a cache file.
164 *
165 * @param string $entryIdentifier An identifier which describes the cache entry to load
166 * @return mixed The cache entry's content as a string or FALSE if the cache entry could not be loaded
167 * @throws \InvalidArgumentException If identifier is invalid
168 * @api
169 */
170 public function get($entryIdentifier)
171 {
172 if ($this->frozen === true) {
173 return isset($this->cacheEntryIdentifiers[$entryIdentifier]) ? file_get_contents($this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension) : false;
174 }
175 if ($entryIdentifier !== PathUtility::basename($entryIdentifier)) {
176 throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1282073033);
177 }
178 $pathAndFilename = $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension;
179 if ($this->isCacheFileExpired($pathAndFilename)) {
180 return false;
181 }
182 $dataSize = (int)file_get_contents(
183 $pathAndFilename,
184 null,
185 null,
186 filesize($pathAndFilename) - self::DATASIZE_DIGITS,
187 self::DATASIZE_DIGITS
188 );
189 return file_get_contents($pathAndFilename, null, null, 0, $dataSize);
190 }
191
192 /**
193 * Checks if a cache entry with the specified identifier exists.
194 *
195 * @param string $entryIdentifier
196 * @return bool TRUE if such an entry exists, FALSE if not
197 * @throws \InvalidArgumentException
198 * @api
199 */
200 public function has($entryIdentifier)
201 {
202 if ($this->frozen === true) {
203 return isset($this->cacheEntryIdentifiers[$entryIdentifier]);
204 }
205 if ($entryIdentifier !== PathUtility::basename($entryIdentifier)) {
206 throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1282073034);
207 }
208 return !$this->isCacheFileExpired($this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension);
209 }
210
211 /**
212 * Removes all cache entries matching the specified identifier.
213 * Usually this only affects one entry.
214 *
215 * @param string $entryIdentifier Specifies the cache entry to remove
216 * @return bool TRUE if (at least) an entry could be removed or FALSE if no entry was found
217 * @throws \RuntimeException
218 * @throws \InvalidArgumentException
219 * @api
220 */
221 public function remove($entryIdentifier)
222 {
223 if ($entryIdentifier !== PathUtility::basename($entryIdentifier)) {
224 throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1282073035);
225 }
226 if ($entryIdentifier === '') {
227 throw new \InvalidArgumentException('The specified entry identifier must not be empty.', 1298114279);
228 }
229 if ($this->frozen === true) {
230 throw new \RuntimeException(sprintf('Cannot remove cache entry because the backend of cache "%s" is frozen.', $this->cacheIdentifier), 1323344193);
231 }
232 $pathAndFilename = $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension;
233 if (file_exists($pathAndFilename) === false) {
234 return false;
235 }
236 if (@unlink($pathAndFilename) === false) {
237 return false;
238 }
239 return true;
240 }
241
242 /**
243 * Finds and returns all cache entry identifiers which are tagged by the
244 * specified tag.
245 *
246 * @param string $searchedTag The tag to search for
247 * @return array An array with identifiers of all matching entries. An empty array if no entries matched
248 * @api
249 */
250 public function findIdentifiersByTag($searchedTag)
251 {
252 $entryIdentifiers = [];
253 $now = $GLOBALS['EXEC_TIME'];
254 $cacheEntryFileExtensionLength = strlen($this->cacheEntryFileExtension);
255 for ($directoryIterator = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\DirectoryIterator::class, $this->cacheDirectory); $directoryIterator->valid(); $directoryIterator->next()) {
256 if ($directoryIterator->isDot()) {
257 continue;
258 }
259 $cacheEntryPathAndFilename = $directoryIterator->getPathname();
260 $index = (int)file_get_contents(
261 $cacheEntryPathAndFilename,
262 null,
263 null,
264 filesize($cacheEntryPathAndFilename) - self::DATASIZE_DIGITS,
265 self::DATASIZE_DIGITS
266 );
267 $metaData = file_get_contents($cacheEntryPathAndFilename, null, null, $index);
268 $expiryTime = (int)substr($metaData, 0, self::EXPIRYTIME_LENGTH);
269 if ($expiryTime !== 0 && $expiryTime < $now) {
270 continue;
271 }
272 if (in_array($searchedTag, explode(' ', substr($metaData, self::EXPIRYTIME_LENGTH, -self::DATASIZE_DIGITS)))) {
273 if ($cacheEntryFileExtensionLength > 0) {
274 $entryIdentifiers[] = substr($directoryIterator->getFilename(), 0, -$cacheEntryFileExtensionLength);
275 } else {
276 $entryIdentifiers[] = $directoryIterator->getFilename();
277 }
278 }
279 }
280 return $entryIdentifiers;
281 }
282
283 /**
284 * Removes all cache entries of this cache and sets the frozen flag to FALSE.
285 *
286 * @api
287 */
288 public function flush()
289 {
290 parent::flush();
291 if ($this->frozen === true) {
292 $this->frozen = false;
293 }
294 }
295
296 /**
297 * Removes all cache entries of this cache which are tagged by the specified tag.
298 *
299 * @param string $tag The tag the entries must have
300 * @api
301 */
302 public function flushByTag($tag)
303 {
304 $identifiers = $this->findIdentifiersByTag($tag);
305 if (empty($identifiers)) {
306 return;
307 }
308 foreach ($identifiers as $entryIdentifier) {
309 $this->remove($entryIdentifier);
310 }
311 }
312
313 /**
314 * Checks if the given cache entry files are still valid or if their
315 * lifetime has exceeded.
316 *
317 * @param string $cacheEntryPathAndFilename
318 * @return bool
319 * @api
320 */
321 protected function isCacheFileExpired($cacheEntryPathAndFilename)
322 {
323 if (file_exists($cacheEntryPathAndFilename) === false) {
324 return true;
325 }
326 $index = (int)file_get_contents(
327 $cacheEntryPathAndFilename,
328 null,
329 null,
330 filesize($cacheEntryPathAndFilename) - self::DATASIZE_DIGITS,
331 self::DATASIZE_DIGITS
332 );
333 $expiryTime = (int)file_get_contents($cacheEntryPathAndFilename, null, null, $index, self::EXPIRYTIME_LENGTH);
334 return $expiryTime !== 0 && $expiryTime < $GLOBALS['EXEC_TIME'];
335 }
336
337 /**
338 * Does garbage collection
339 *
340 * @api
341 */
342 public function collectGarbage()
343 {
344 if ($this->frozen === true) {
345 return;
346 }
347 for ($directoryIterator = new \DirectoryIterator($this->cacheDirectory); $directoryIterator->valid(); $directoryIterator->next()) {
348 if ($directoryIterator->isDot()) {
349 continue;
350 }
351 if ($this->isCacheFileExpired($directoryIterator->getPathname())) {
352 $cacheEntryFileExtensionLength = strlen($this->cacheEntryFileExtension);
353 if ($cacheEntryFileExtensionLength > 0) {
354 $this->remove(substr($directoryIterator->getFilename(), 0, -$cacheEntryFileExtensionLength));
355 } else {
356 $this->remove($directoryIterator->getFilename());
357 }
358 }
359 }
360 }
361
362 /**
363 * Tries to find the cache entry for the specified identifier.
364 * Usually only one cache entry should be found - if more than one exist, this
365 * is due to some error or crash.
366 *
367 * @param string $entryIdentifier The cache entry identifier
368 * @return mixed The filenames (including path) as an array if one or more entries could be found, otherwise FALSE
369 */
370 protected function findCacheFilesByIdentifier($entryIdentifier)
371 {
372 $pattern = $this->cacheDirectory . $entryIdentifier;
373 $filesFound = glob($pattern);
374 if ($filesFound === false || empty($filesFound)) {
375 return false;
376 }
377 return $filesFound;
378 }
379
380 /**
381 * Loads PHP code from the cache and require_onces it right away.
382 *
383 * @param string $entryIdentifier An identifier which describes the cache entry to load
384 * @throws \InvalidArgumentException
385 * @return mixed Potential return value from the include operation
386 * @api
387 */
388 public function requireOnce($entryIdentifier)
389 {
390 if ($this->frozen === true) {
391 if (isset($this->cacheEntryIdentifiers[$entryIdentifier])) {
392 return require_once $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension;
393 }
394 return false;
395 }
396 if ($entryIdentifier !== PathUtility::basename($entryIdentifier)) {
397 throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1282073036);
398 }
399 $pathAndFilename = $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension;
400 return $this->isCacheFileExpired($pathAndFilename) ? false : require_once $pathAndFilename;
401 }
402 }