Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 66
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
RateLimiterManagerFactory
0.00% covered (danger)
0.00%
0 / 66
0.00% covered (danger)
0.00%
0 / 4
132
0.00% covered (danger)
0.00%
0 / 1
 __invoke
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 getRateLimiter
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 createCache
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
20
 createRedisCache
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3/**
4 * Rate limiter manager factory.
5 *
6 * PHP version 8
7 *
8 * Copyright (C) The National Library of Finland 2024.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Service
25 * @author   Ere Maijala <ere.maijala@helsinki.fi>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org/wiki/development Wiki
28 */
29
30namespace VuFind\RateLimiter;
31
32use Closure;
33use Laminas\Cache\Psr\CacheItemPool\CacheItemPoolDecorator;
34use Laminas\Cache\Storage\Capabilities;
35use Laminas\ServiceManager\Exception\ServiceNotCreatedException;
36use Laminas\ServiceManager\Exception\ServiceNotFoundException;
37use Laminas\ServiceManager\Factory\FactoryInterface;
38use Psr\Container\ContainerExceptionInterface as ContainerException;
39use Psr\Container\ContainerInterface;
40use stdClass;
41use Symfony\Component\RateLimiter\LimiterInterface;
42use Symfony\Component\RateLimiter\RateLimiterFactory;
43use Symfony\Component\RateLimiter\Storage\CacheStorage;
44use Symfony\Component\RateLimiter\Storage\StorageInterface;
45use VuFind\RateLimiter\Storage\CredisStorage;
46
47/**
48 * Rate limiter manager factory.
49 *
50 * @category VuFind
51 * @package  Service
52 * @author   Ere Maijala <ere.maijala@helsinki.fi>
53 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
54 * @link     https://vufind.org/wiki/development Wiki
55 */
56class RateLimiterManagerFactory implements FactoryInterface
57{
58    /**
59     * Service locator
60     *
61     * @var ContainerInterface
62     */
63    protected $serviceLocator;
64
65    /**
66     * Create an object
67     *
68     * @param ContainerInterface $container     Service manager
69     * @param string             $requestedName Service being created
70     * @param null|array         $options       Extra options (optional)
71     *
72     * @return object
73     *
74     * @throws ServiceNotFoundException if unable to resolve the service.
75     * @throws ServiceNotCreatedException if an exception is raised when
76     * creating a service.
77     * @throws ContainerException&\Throwable if any other error occurs
78     */
79    public function __invoke(
80        ContainerInterface $container,
81        $requestedName,
82        array $options = null
83    ) {
84        if (!empty($options)) {
85            throw new \Exception('Unexpected options sent to factory.');
86        }
87
88        $this->serviceLocator = $container;
89
90        $yamlReader = $container->get(\VuFind\Config\YamlReader::class);
91        $config = $yamlReader->get('RateLimiter.yaml');
92
93        $authManager = $container->get(\VuFind\Auth\Manager::class);
94        $request = $container->get('Request');
95
96        return new $requestedName(
97            $config,
98            $request->getServer('REMOTE_ADDR'),
99            $authManager->getUserObject()?->getId(),
100            Closure::fromCallable([$this, 'getRateLimiter']),
101            $container->get(\VuFind\Net\IpAddressUtils::class)
102        );
103    }
104
105    /**
106     * Get rate limiter
107     *
108     * @param array   $config   Rate limiter configuration
109     * @param string  $policyId Policy ID
110     * @param string  $clientIp Client's IP address
111     * @param ?string $userId   User ID or null if not logged in
112     *
113     * @return LimiterInterface
114     */
115    protected function getRateLimiter(
116        array $config,
117        string $policyId,
118        string $clientIp,
119        ?string $userId
120    ): LimiterInterface {
121        $policy = $config['Policies'][$policyId] ?? [];
122        $rateLimiterConfig = $policy['rateLimiterSettings'] ?? [];
123        $rateLimiterConfig['id'] = $policyId;
124        if (null !== $userId && !($policy['preferIPAddress'] ?? false)) {
125            $clientId = "u:$userId";
126        } else {
127            $clientId = "ip:$clientIp";
128        }
129        $factory = new RateLimiterFactory($rateLimiterConfig, $this->createCache($config));
130        return $factory->create($clientId);
131    }
132
133    /**
134     * Create cache for the rate limiter
135     *
136     * @param array $config Rate limiter configuration
137     *
138     * @return ?StorageInterface
139     */
140    protected function createCache(array $config): StorageInterface
141    {
142        $storageConfig = $config['Storage'] ?? [];
143        $adapter = $storageConfig['adapter'] ?? 'memcached';
144        $storageConfig['options']['namespace'] ??= 'RateLimiter';
145
146        // Handle Redis cache separately:
147        $adapterLc = strtolower($adapter);
148        if ('redis' === $adapterLc) {
149            return $this->createRedisCache($storageConfig);
150        }
151
152        if ('vufind' === $adapterLc) {
153            // Use cache manager for "VuFind" cache (only for testing purposes):
154            $cacheManager = $this->serviceLocator->get(\VuFind\Cache\Manager::class);
155            $laminasCache = $cacheManager->getCache('object', $storageConfig['options']['namespace']);
156            // Fake the capabilities to include static TTL support:
157            $eventManager = $laminasCache->getEventManager();
158            $eventManager->attach(
159                'getCapabilities.post',
160                function ($event) use ($laminasCache) {
161                    $oldCapacities = $event->getResult();
162                    $newCapacities = new Capabilities(
163                        $laminasCache,
164                        new stdClass(),
165                        ['staticTtl' => true],
166                        $oldCapacities
167                    );
168                    $event->setResult($newCapacities);
169                }
170            );
171        } else {
172            if ('memcached' === $adapterLc) {
173                $storageConfig['options']['servers'] ??= 'localhost:11211';
174            }
175
176            // Laminas cache:
177            $settings = [
178                'adapter' => $adapter,
179                'options' => $storageConfig['options'],
180            ];
181            $laminasCache = $this->serviceLocator
182                ->get(\Laminas\Cache\Service\StorageAdapterFactory::class)
183                ->createFromArrayConfiguration($settings);
184        }
185
186        return new CacheStorage(new CacheItemPoolDecorator($laminasCache));
187    }
188
189    /**
190     * Create Redis cache for the rate limiter
191     *
192     * @param array $storageConfig Storage configuration
193     *
194     * @return ?StorageInterface
195     */
196    protected function createRedisCache(array $storageConfig): StorageInterface
197    {
198        // Set defaults if nothing set in config file:
199        $options = $storageConfig['options'];
200        $host = $options['redis_host'] ?? 'localhost';
201        $port = $options['redis_port'] ?? 6379;
202        $timeout = $options['redis_connection_timeout'] ?? 0.5;
203        $password = $options['redis_auth'] ?? null;
204        $username = $options['redis_user'] ?? null;
205        $redisDb = $options['redis_db'] ?? 0;
206
207        // Create Credis client, the connection is established lazily:
208        $redis = new \Credis_Client($host, $port, $timeout, '', $redisDb, $password, $username);
209        if ($options['redis_standalone'] ?? true) {
210            $redis->forceStandalone();
211        }
212
213        return new CredisStorage($redis, $options);
214    }
215}