Subversion-Projekte lars-tiefland.laravel_shop

Revision

Revision 148 | Zur aktuellen Revision | Details | Vergleich mit vorheriger | Letzte Änderung | Log anzeigen | RSS feed

Revision Autor Zeilennr. Zeile
148 lars 1
<?php
2
 
3
/*
4
 * This file is part of the Symfony package.
5
 *
6
 * (c) Fabien Potencier <fabien@symfony.com>
7
 *
8
 * This code is partially based on the Rack-Cache library by Ryan Tomayko,
9
 * which is released under the MIT license.
10
 *
11
 * For the full copyright and license information, please view the LICENSE
12
 * file that was distributed with this source code.
13
 */
14
 
15
namespace Symfony\Component\HttpKernel\HttpCache;
16
 
17
use Symfony\Component\HttpFoundation\Request;
18
use Symfony\Component\HttpFoundation\Response;
19
 
20
/**
21
 * Store implements all the logic for storing cache metadata (Request and Response headers).
22
 *
23
 * @author Fabien Potencier <fabien@symfony.com>
24
 */
25
class Store implements StoreInterface
26
{
27
    protected $root;
28
    /** @var \SplObjectStorage<Request, string> */
29
    private \SplObjectStorage $keyCache;
30
    /** @var array<string, resource> */
31
    private array $locks = [];
399 lars 32
    private array $options;
148 lars 33
 
34
    /**
399 lars 35
     * Constructor.
36
     *
37
     * The available options are:
38
     *
39
     *   * private_headers  Set of response headers that should not be stored
40
     *                      when a response is cached. (default: Set-Cookie)
41
     *
148 lars 42
     * @throws \RuntimeException
43
     */
399 lars 44
    public function __construct(string $root, array $options = [])
148 lars 45
    {
46
        $this->root = $root;
47
        if (!is_dir($this->root) && !@mkdir($this->root, 0777, true) && !is_dir($this->root)) {
48
            throw new \RuntimeException(sprintf('Unable to create the store directory (%s).', $this->root));
49
        }
50
        $this->keyCache = new \SplObjectStorage();
399 lars 51
        $this->options = array_merge([
52
            'private_headers' => ['Set-Cookie'],
53
        ], $options);
148 lars 54
    }
55
 
56
    /**
57
     * Cleanups storage.
58
     */
59
    public function cleanup()
60
    {
61
        // unlock everything
62
        foreach ($this->locks as $lock) {
63
            flock($lock, \LOCK_UN);
64
            fclose($lock);
65
        }
66
 
67
        $this->locks = [];
68
    }
69
 
70
    /**
71
     * Tries to lock the cache for a given Request, without blocking.
72
     *
73
     * @return bool|string true if the lock is acquired, the path to the current lock otherwise
74
     */
75
    public function lock(Request $request): bool|string
76
    {
77
        $key = $this->getCacheKey($request);
78
 
79
        if (!isset($this->locks[$key])) {
80
            $path = $this->getPath($key);
81
            if (!is_dir(\dirname($path)) && false === @mkdir(\dirname($path), 0777, true) && !is_dir(\dirname($path))) {
82
                return $path;
83
            }
84
            $h = fopen($path, 'c');
85
            if (!flock($h, \LOCK_EX | \LOCK_NB)) {
86
                fclose($h);
87
 
88
                return $path;
89
            }
90
 
91
            $this->locks[$key] = $h;
92
        }
93
 
94
        return true;
95
    }
96
 
97
    /**
98
     * Releases the lock for the given Request.
99
     *
100
     * @return bool False if the lock file does not exist or cannot be unlocked, true otherwise
101
     */
102
    public function unlock(Request $request): bool
103
    {
104
        $key = $this->getCacheKey($request);
105
 
106
        if (isset($this->locks[$key])) {
107
            flock($this->locks[$key], \LOCK_UN);
108
            fclose($this->locks[$key]);
109
            unset($this->locks[$key]);
110
 
111
            return true;
112
        }
113
 
114
        return false;
115
    }
116
 
117
    public function isLocked(Request $request): bool
118
    {
119
        $key = $this->getCacheKey($request);
120
 
121
        if (isset($this->locks[$key])) {
122
            return true; // shortcut if lock held by this process
123
        }
124
 
125
        if (!is_file($path = $this->getPath($key))) {
126
            return false;
127
        }
128
 
129
        $h = fopen($path, 'r');
130
        flock($h, \LOCK_EX | \LOCK_NB, $wouldBlock);
131
        flock($h, \LOCK_UN); // release the lock we just acquired
132
        fclose($h);
133
 
134
        return (bool) $wouldBlock;
135
    }
136
 
137
    /**
138
     * Locates a cached Response for the Request provided.
139
     */
140
    public function lookup(Request $request): ?Response
141
    {
142
        $key = $this->getCacheKey($request);
143
 
144
        if (!$entries = $this->getMetadata($key)) {
145
            return null;
146
        }
147
 
148
        // find a cached entry that matches the request.
149
        $match = null;
150
        foreach ($entries as $entry) {
151
            if ($this->requestsMatch(isset($entry[1]['vary'][0]) ? implode(', ', $entry[1]['vary']) : '', $request->headers->all(), $entry[0])) {
152
                $match = $entry;
153
 
154
                break;
155
            }
156
        }
157
 
158
        if (null === $match) {
159
            return null;
160
        }
161
 
162
        $headers = $match[1];
163
        if (file_exists($path = $this->getPath($headers['x-content-digest'][0]))) {
164
            return $this->restoreResponse($headers, $path);
165
        }
166
 
167
        // TODO the metaStore referenced an entity that doesn't exist in
168
        // the entityStore. We definitely want to return nil but we should
169
        // also purge the entry from the meta-store when this is detected.
170
        return null;
171
    }
172
 
173
    /**
174
     * Writes a cache entry to the store for the given Request and Response.
175
     *
176
     * Existing entries are read and any that match the response are removed. This
177
     * method calls write with the new list of cache entries.
178
     *
179
     * @throws \RuntimeException
180
     */
181
    public function write(Request $request, Response $response): string
182
    {
183
        $key = $this->getCacheKey($request);
184
        $storedEnv = $this->persistRequest($request);
185
 
186
        if ($response->headers->has('X-Body-File')) {
187
            // Assume the response came from disk, but at least perform some safeguard checks
188
            if (!$response->headers->has('X-Content-Digest')) {
189
                throw new \RuntimeException('A restored response must have the X-Content-Digest header.');
190
            }
191
 
192
            $digest = $response->headers->get('X-Content-Digest');
193
            if ($this->getPath($digest) !== $response->headers->get('X-Body-File')) {
194
                throw new \RuntimeException('X-Body-File and X-Content-Digest do not match.');
195
            }
196
            // Everything seems ok, omit writing content to disk
197
        } else {
198
            $digest = $this->generateContentDigest($response);
199
            $response->headers->set('X-Content-Digest', $digest);
200
 
201
            if (!$this->save($digest, $response->getContent(), false)) {
202
                throw new \RuntimeException('Unable to store the entity.');
203
            }
204
 
205
            if (!$response->headers->has('Transfer-Encoding')) {
206
                $response->headers->set('Content-Length', \strlen($response->getContent()));
207
            }
208
        }
209
 
210
        // read existing cache entries, remove non-varying, and add this one to the list
211
        $entries = [];
212
        $vary = $response->headers->get('vary');
213
        foreach ($this->getMetadata($key) as $entry) {
214
            if (!isset($entry[1]['vary'][0])) {
215
                $entry[1]['vary'] = [''];
216
            }
217
 
218
            if ($entry[1]['vary'][0] != $vary || !$this->requestsMatch($vary ?? '', $entry[0], $storedEnv)) {
219
                $entries[] = $entry;
220
            }
221
        }
222
 
223
        $headers = $this->persistResponse($response);
224
        unset($headers['age']);
225
 
399 lars 226
        foreach ($this->options['private_headers'] as $h) {
227
            unset($headers[strtolower($h)]);
228
        }
229
 
148 lars 230
        array_unshift($entries, [$storedEnv, $headers]);
231
 
232
        if (!$this->save($key, serialize($entries))) {
233
            throw new \RuntimeException('Unable to store the metadata.');
234
        }
235
 
236
        return $key;
237
    }
238
 
239
    /**
240
     * Returns content digest for $response.
241
     */
242
    protected function generateContentDigest(Response $response): string
243
    {
244
        return 'en'.hash('xxh128', $response->getContent());
245
    }
246
 
247
    /**
248
     * Invalidates all cache entries that match the request.
249
     *
250
     * @throws \RuntimeException
251
     */
252
    public function invalidate(Request $request)
253
    {
254
        $modified = false;
255
        $key = $this->getCacheKey($request);
256
 
257
        $entries = [];
258
        foreach ($this->getMetadata($key) as $entry) {
259
            $response = $this->restoreResponse($entry[1]);
260
            if ($response->isFresh()) {
261
                $response->expire();
262
                $modified = true;
263
                $entries[] = [$entry[0], $this->persistResponse($response)];
264
            } else {
265
                $entries[] = $entry;
266
            }
267
        }
268
 
269
        if ($modified && !$this->save($key, serialize($entries))) {
270
            throw new \RuntimeException('Unable to store the metadata.');
271
        }
272
    }
273
 
274
    /**
275
     * Determines whether two Request HTTP header sets are non-varying based on
276
     * the vary response header value provided.
277
     *
278
     * @param string|null $vary A Response vary header
279
     * @param array       $env1 A Request HTTP header array
280
     * @param array       $env2 A Request HTTP header array
281
     */
282
    private function requestsMatch(?string $vary, array $env1, array $env2): bool
283
    {
284
        if (empty($vary)) {
285
            return true;
286
        }
287
 
288
        foreach (preg_split('/[\s,]+/', $vary) as $header) {
289
            $key = str_replace('_', '-', strtolower($header));
290
            $v1 = $env1[$key] ?? null;
291
            $v2 = $env2[$key] ?? null;
292
            if ($v1 !== $v2) {
293
                return false;
294
            }
295
        }
296
 
297
        return true;
298
    }
299
 
300
    /**
301
     * Gets all data associated with the given key.
302
     *
303
     * Use this method only if you know what you are doing.
304
     */
305
    private function getMetadata(string $key): array
306
    {
307
        if (!$entries = $this->load($key)) {
308
            return [];
309
        }
310
 
311
        return unserialize($entries) ?: [];
312
    }
313
 
314
    /**
315
     * Purges data for the given URL.
316
     *
317
     * This method purges both the HTTP and the HTTPS version of the cache entry.
318
     *
319
     * @return bool true if the URL exists with either HTTP or HTTPS scheme and has been purged, false otherwise
320
     */
321
    public function purge(string $url): bool
322
    {
323
        $http = preg_replace('#^https:#', 'http:', $url);
324
        $https = preg_replace('#^http:#', 'https:', $url);
325
 
326
        $purgedHttp = $this->doPurge($http);
327
        $purgedHttps = $this->doPurge($https);
328
 
329
        return $purgedHttp || $purgedHttps;
330
    }
331
 
332
    /**
333
     * Purges data for the given URL.
334
     */
335
    private function doPurge(string $url): bool
336
    {
337
        $key = $this->getCacheKey(Request::create($url));
338
        if (isset($this->locks[$key])) {
339
            flock($this->locks[$key], \LOCK_UN);
340
            fclose($this->locks[$key]);
341
            unset($this->locks[$key]);
342
        }
343
 
344
        if (is_file($path = $this->getPath($key))) {
345
            unlink($path);
346
 
347
            return true;
348
        }
349
 
350
        return false;
351
    }
352
 
353
    /**
354
     * Loads data for the given key.
355
     */
356
    private function load(string $key): ?string
357
    {
358
        $path = $this->getPath($key);
359
 
360
        return is_file($path) && false !== ($contents = @file_get_contents($path)) ? $contents : null;
361
    }
362
 
363
    /**
364
     * Save data for the given key.
365
     */
366
    private function save(string $key, string $data, bool $overwrite = true): bool
367
    {
368
        $path = $this->getPath($key);
369
 
370
        if (!$overwrite && file_exists($path)) {
371
            return true;
372
        }
373
 
374
        if (isset($this->locks[$key])) {
375
            $fp = $this->locks[$key];
376
            @ftruncate($fp, 0);
377
            @fseek($fp, 0);
378
            $len = @fwrite($fp, $data);
379
            if (\strlen($data) !== $len) {
380
                @ftruncate($fp, 0);
381
 
382
                return false;
383
            }
384
        } else {
385
            if (!is_dir(\dirname($path)) && false === @mkdir(\dirname($path), 0777, true) && !is_dir(\dirname($path))) {
386
                return false;
387
            }
388
 
389
            $tmpFile = tempnam(\dirname($path), basename($path));
390
            if (false === $fp = @fopen($tmpFile, 'w')) {
391
                @unlink($tmpFile);
392
 
393
                return false;
394
            }
395
            @fwrite($fp, $data);
396
            @fclose($fp);
397
 
398
            if ($data != file_get_contents($tmpFile)) {
399
                @unlink($tmpFile);
400
 
401
                return false;
402
            }
403
 
404
            if (false === @rename($tmpFile, $path)) {
405
                @unlink($tmpFile);
406
 
407
                return false;
408
            }
409
        }
410
 
411
        @chmod($path, 0666 & ~umask());
412
 
413
        return true;
414
    }
415
 
416
    public function getPath(string $key)
417
    {
418
        return $this->root.\DIRECTORY_SEPARATOR.substr($key, 0, 2).\DIRECTORY_SEPARATOR.substr($key, 2, 2).\DIRECTORY_SEPARATOR.substr($key, 4, 2).\DIRECTORY_SEPARATOR.substr($key, 6);
419
    }
420
 
421
    /**
422
     * Generates a cache key for the given Request.
423
     *
424
     * This method should return a key that must only depend on a
425
     * normalized version of the request URI.
426
     *
427
     * If the same URI can have more than one representation, based on some
428
     * headers, use a Vary header to indicate them, and each representation will
429
     * be stored independently under the same cache key.
430
     */
431
    protected function generateCacheKey(Request $request): string
432
    {
433
        return 'md'.hash('sha256', $request->getUri());
434
    }
435
 
436
    /**
437
     * Returns a cache key for the given Request.
438
     */
439
    private function getCacheKey(Request $request): string
440
    {
441
        if (isset($this->keyCache[$request])) {
442
            return $this->keyCache[$request];
443
        }
444
 
445
        return $this->keyCache[$request] = $this->generateCacheKey($request);
446
    }
447
 
448
    /**
449
     * Persists the Request HTTP headers.
450
     */
451
    private function persistRequest(Request $request): array
452
    {
453
        return $request->headers->all();
454
    }
455
 
456
    /**
457
     * Persists the Response HTTP headers.
458
     */
459
    private function persistResponse(Response $response): array
460
    {
461
        $headers = $response->headers->all();
462
        $headers['X-Status'] = [$response->getStatusCode()];
463
 
464
        return $headers;
465
    }
466
 
467
    /**
468
     * Restores a Response from the HTTP headers and body.
469
     */
470
    private function restoreResponse(array $headers, string $path = null): Response
471
    {
472
        $status = $headers['X-Status'][0];
473
        unset($headers['X-Status']);
474
 
475
        if (null !== $path) {
476
            $headers['X-Body-File'] = [$path];
477
        }
478
 
479
        return new Response($path, $status, $headers);
480
    }
481
}