Passed
Pull Request — main (#442)
by MusikAnimal
08:21 queued 04:15
created

ProjectRepository::getUsersInGroups()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 34
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 18
nc 3
nop 3
dl 0
loc 34
rs 9.6666
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types = 1);
4
5
namespace App\Repository;
6
7
use App\Model\PageAssessments;
8
use App\Model\Project;
9
use Doctrine\DBAL\Connection;
10
use Exception;
11
use GuzzleHttp\Client;
12
use Psr\Cache\CacheItemPoolInterface;
13
use Psr\Container\ContainerInterface;
14
use Psr\Log\LoggerInterface;
15
16
/**
17
 * This class provides data to the Project class.
18
 * @codeCoverageIgnore
19
 */
20
class ProjectRepository extends Repository
21
{
22
    protected PageAssessmentsRepository $assessmentsRepo;
23
24
    /** @var array Project's 'dbName', 'url' and 'lang'. */
25
    protected array $basicInfo;
26
27
    /** @var string[] Basic metadata if XTools is in single-wiki mode. */
28
    protected array $singleBasicInfo;
29
30
    /** @var array Full Project metadata, including $basicInfo. */
31
    protected array $metadata;
32
33
    /** @var string The cache key for the 'all project' metadata. */
34
    protected string $cacheKeyAllProjects = 'allprojects';
35
36
    public function __construct(
37
        ContainerInterface $container,
38
        CacheItemPoolInterface $cache,
39
        Client $guzzle,
40
        LoggerInterface $logger,
41
        bool $isWMF,
42
        int $queryTimeout,
43
        PageAssessmentsRepository $assessmentsRepo
44
    ) {
45
        $this->assessmentsRepo = $assessmentsRepo;
46
        parent::__construct($container, $cache, $guzzle, $logger, $isWMF, $queryTimeout);
47
    }
48
49
    /**
50
     * Convenience method to get a new Project object based on a given identification string.
51
     * @param string $projectIdent The domain name, database name, or URL of a project.
52
     * @return Project
53
     */
54
    public function getProject(string $projectIdent): Project
55
    {
56
        $project = new Project($projectIdent);
57
        $project->setRepository($this);
58
        $project->setPageAssessments(new PageAssessments($this->assessmentsRepo, $project));
59
60
        if ($this->container->getParameter('app.single_wiki')) {
0 ignored issues
show
Bug introduced by
The method getParameter() does not exist on Psr\Container\ContainerInterface. It seems like you code against a sub-type of Psr\Container\ContainerInterface such as Symfony\Component\Depend...tion\ContainerInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

60
        if ($this->container->/** @scrutinizer ignore-call */ getParameter('app.single_wiki')) {
Loading history...
61
            $this->setSingleBasicInfo([
62
                'url' => $this->container->getParameter('wiki_url'),
63
                'dbName' => '', // Just so this will pass in CI.
64
                // TODO: this will need to be restored for third party support; KEYWORD: isWMF
65
                // 'dbName' => $container->getParameter('database_replica_name'),
66
            ]);
67
        }
68
69
        return $project;
70
    }
71
72
    /**
73
     * Get the XTools default project.
74
     * @return Project
75
     */
76
    public function getDefaultProject(): Project
77
    {
78
        $defaultProjectName = $this->container->getParameter('default_project');
79
        return $this->getProject($defaultProjectName);
80
    }
81
82
    /**
83
     * Get the global 'meta' project, which is either Meta (if this is Labs) or the default project.
84
     * @return Project
85
     */
86
    public function getGlobalProject(): Project
87
    {
88
        if ($this->isWMF) {
89
            return $this->getProject('metawiki');
90
        } else {
91
            return $this->getDefaultProject();
92
        }
93
    }
94
95
    /**
96
     * For single-wiki installations, you must manually set the wiki URL and database name
97
     * (because there's no meta.wiki database to query).
98
     * @param array $metadata
99
     * @throws Exception
100
     */
101
    public function setSingleBasicInfo(array $metadata): void
102
    {
103
        if (!array_key_exists('url', $metadata) || !array_key_exists('dbName', $metadata)) {
104
            $error = "Single-wiki metadata should contain 'url', 'dbName' and 'lang' keys.";
105
            throw new Exception($error);
106
        }
107
        $this->singleBasicInfo = array_intersect_key($metadata, [
108
            'url' => '',
109
            'dbName' => '',
110
            'lang' => '',
111
        ]);
112
    }
113
114
    /**
115
     * Get the 'dbName', 'url' and 'lang' of all projects.
116
     * @return string[][] Each item has 'dbName', 'url' and 'lang' keys.
117
     */
118
    public function getAll(): array
119
    {
120
        $this->logger->debug(__METHOD__." Getting all projects' metadata");
121
        // Single wiki mode?
122
        if (!empty($this->singleBasicInfo)) {
123
            return [$this->getOne('')];
124
        }
125
126
        // Maybe we've already fetched it.
127
        if ($this->cache->hasItem($this->cacheKeyAllProjects)) {
128
            return $this->cache->getItem($this->cacheKeyAllProjects)->get();
129
        }
130
131
        if ($this->container->hasParameter("database_meta_table")) {
0 ignored issues
show
Bug introduced by
The method hasParameter() does not exist on Psr\Container\ContainerInterface. It seems like you code against a sub-type of Psr\Container\ContainerInterface such as Symfony\Component\Depend...tion\ContainerInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

131
        if ($this->container->/** @scrutinizer ignore-call */ hasParameter("database_meta_table")) {
Loading history...
132
            $table = $this->container->getParameter('database_meta_name') . '.' .
133
                $this->container->getParameter('database_meta_table');
134
        } else {
135
            $table = "meta_p.wiki";
136
        }
137
138
        // Otherwise, fetch all from the database.
139
        $sql = "SELECT dbname AS dbName, url, lang FROM $table";
140
        $projects = $this->executeProjectsQuery('meta', $sql)
141
            ->fetchAllAssociative();
142
        $projectsMetadata = [];
143
        foreach ($projects as $project) {
144
            $projectsMetadata[$project['dbName']] = $project;
145
        }
146
147
        // Cache for one day and return.
148
        return $this->setCache(
149
            $this->cacheKeyAllProjects,
150
            $projectsMetadata,
151
            'P1D'
152
        );
153
    }
154
155
    /**
156
     * Get the 'dbName', 'url' and 'lang' of a project. This is all you need to make database queries.
157
     * More comprehensive metadata can be fetched with getMetadata() at the expense of an API call.
158
     * @param string $project A project URL, domain name, or database name.
159
     * @return string[]|null With 'dbName', 'url' and 'lang' keys; or null if not found.
160
     */
161
    public function getOne(string $project): ?array
162
    {
163
        $this->logger->debug(__METHOD__." Getting metadata about $project");
164
        // For single-wiki setups, every project is the same.
165
        if (isset($this->singleBasicInfo)) {
166
            return $this->singleBasicInfo;
167
        }
168
169
        // Remove _p suffix.
170
        $project = rtrim($project, '_p');
171
172
        // For multi-wiki setups, first check the cache.
173
        // First the all-projects cache, then the individual one.
174
        if ($this->cache->hasItem($this->cacheKeyAllProjects)) {
175
            foreach ($this->cache->getItem($this->cacheKeyAllProjects)->get() as $projMetadata) {
176
                if ($projMetadata['dbName'] == "$project"
177
                    || $projMetadata['url'] == "$project"
178
                    || $projMetadata['url'] == "https://$project"
179
                    || $projMetadata['url'] == "https://$project.org"
180
                    || $projMetadata['url'] == "https://www.$project") {
181
                    $this->logger->debug(__METHOD__ . " Using cached data for $project");
182
                    return $projMetadata;
183
                }
184
            }
185
        }
186
        $cacheKey = $this->getCacheKey($project, 'project');
187
        if ($this->cache->hasItem($cacheKey)) {
188
            return $this->cache->getItem($cacheKey)->get();
189
        }
190
191
        // TODO: make this configurable if XTools is to work on 3rd party wiki farms
192
        $table = "meta_p.wiki";
193
194
        // Otherwise, fetch the project's metadata from the meta.wiki table.
195
        $sql = "SELECT dbname AS dbName, url, lang
196
                FROM $table
197
                WHERE dbname = :project
198
                    OR url LIKE :projectUrl
199
                    OR url LIKE :projectUrl2
200
                    OR url LIKE :projectUrl3
201
                    OR url LIKE :projectUrl4";
202
        $basicInfo = $this->executeProjectsQuery('meta', $sql, [
203
            'project' => $project,
204
            'projectUrl' => "https://$project",
205
            'projectUrl2' => "https://$project.org",
206
            'projectUrl3' => "https://www.$project",
207
            'projectUrl4' => "https://www.$project.org",
208
        ])->fetchAssociative();
209
        $basicInfo = false === $basicInfo ? null : $basicInfo;
210
211
        // Cache for one hour and return.
212
        return $this->setCache($cacheKey, $basicInfo, 'PT1H');
213
    }
214
215
    /**
216
     * Get metadata about a project, including the 'dbName', 'url' and 'lang'
217
     *
218
     * @param string $projectUrl The project's URL.
219
     * @return array|null With 'dbName', 'url', 'lang', 'general' and 'namespaces' keys.
220
     *   'general' contains: 'wikiName', 'articlePath', 'scriptPath', 'script',
221
     *   'timezone', and 'timezoneOffset'; 'namespaces' contains all namespace
222
     *   names, keyed by their IDs. If this function returns null, the API call
223
     *   failed.
224
     */
225
    public function getMetadata(string $projectUrl): ?array
226
    {
227
        // First try variable cache
228
        if (!empty($this->metadata)) {
229
            return $this->metadata;
230
        }
231
232
        // Redis cache
233
        $cacheKey = $this->getCacheKey(
234
            // Removed non-alphanumeric characters
235
            preg_replace("/[^A-Za-z0-9]/", '', $projectUrl),
236
            'project_metadata'
237
        );
238
239
        if ($this->cache->hasItem($cacheKey)) {
240
            $this->metadata = $this->cache->getItem($cacheKey)->get();
241
            return $this->metadata;
242
        }
243
244
        try {
245
            $res = json_decode($this->guzzle->request('GET', $projectUrl.$this->getApiPath(), [
246
                'query' => [
247
                    'action' => 'query',
248
                    'meta' => 'siteinfo',
249
                    'siprop' => 'general|namespaces',
250
                    'format' => 'json',
251
                ],
252
            ])->getBody()->getContents(), true);
253
        } catch (Exception $e) {
254
            return null;
255
        }
256
257
        $this->metadata = [
258
            'general' => [],
259
            'namespaces' => [],
260
        ];
261
262
        // Even if general info could not be fetched,
263
        //   return dbName, url and lang if already known
264
        if (!empty($this->basicInfo)) {
265
            $this->metadata['dbName'] = $this->basicInfo['dbName'];
266
            $this->metadata['url'] = $this->basicInfo['url'];
267
            $this->metadata['lang'] = $this->basicInfo['lang'];
268
        }
269
270
        if (isset($res['query']['general'])) {
271
            $info = $res['query']['general'];
272
273
            $this->metadata['dbName'] = $info['wikiid'];
274
            $this->metadata['url'] = $info['server'];
275
            $this->metadata['lang'] = $info['lang'];
276
277
            $this->metadata['general'] = [
278
                'wikiName' => $info['sitename'],
279
                'articlePath' => $info['articlepath'],
280
                'scriptPath' => $info['scriptpath'],
281
                'script' => $info['script'],
282
                'timezone' => $info['timezone'],
283
                'timeOffset' => $info['timeoffset'],
284
                'mainpage' => $info['mainpage'],
285
            ];
286
        }
287
288
        $this->setNamespaces($res);
289
290
        // Cache for one hour and return.
291
        return $this->setCache($cacheKey, $this->metadata, 'PT1H');
292
    }
293
294
    /**
295
     * Set the namespaces on $this->metadata.
296
     * @param array $res As produced by meta=siteinfo API.
297
     */
298
    private function setNamespaces(array $res): void
299
    {
300
        if (!isset($res['query']['namespaces'])) {
301
            return;
302
        }
303
304
        foreach ($res['query']['namespaces'] as $namespace) {
305
            if ($namespace['id'] < 0) {
306
                continue;
307
            }
308
309
            if (isset($namespace['name'])) {
310
                $name = $namespace['name'];
311
            } elseif (isset($namespace['*'])) {
312
                $name = $namespace['*'];
313
            } else {
314
                continue;
315
            }
316
317
            $this->metadata['namespaces'][$namespace['id']] = $name;
318
        }
319
    }
320
321
    /**
322
     * Get a list of projects that have opted in to having all their users' restricted statistics available to anyone.
323
     * @return string[]
324
     */
325
    public function optedIn(): array
326
    {
327
        $optedIn = $this->container->getParameter('opted_in');
328
        // In case there's just one given.
329
        if (!is_array($optedIn)) {
330
            $optedIn = [ $optedIn ];
331
        }
332
        return $optedIn;
333
    }
334
335
    /**
336
     * The path to api.php.
337
     * @return string
338
     */
339
    public function getApiPath(): string
340
    {
341
        return $this->container->getParameter('api_path');
342
    }
343
344
    /**
345
     * Check to see if a page exists on this project and has some content.
346
     * @param Project $project The project.
347
     * @param int $namespaceId The page namespace ID.
348
     * @param string $pageTitle The page title, without namespace.
349
     * @return bool
350
     */
351
    public function pageHasContent(Project $project, int $namespaceId, string $pageTitle): bool
352
    {
353
        $pageTable = $this->getTableName($project->getDatabaseName(), 'page');
354
        $query = "SELECT page_id "
355
             . " FROM $pageTable "
356
             . " WHERE page_namespace = :ns AND page_title = :title AND page_len > 0 "
357
             . " LIMIT 1";
358
        $params = [
359
            'ns' => $namespaceId,
360
            'title' => str_replace(' ', '_', $pageTitle),
361
        ];
362
        $pages = $this->executeProjectsQuery($project, $query, $params)
363
            ->fetchAllAssociative();
364
        return count($pages) > 0;
365
    }
366
367
    /**
368
     * Get a list of the extensions installed on the wiki.
369
     * @param Project $project
370
     * @return string[]
371
     */
372
    public function getInstalledExtensions(Project $project): array
373
    {
374
        $res = json_decode($this->guzzle->request('GET', $project->getApiUrl(), ['query' => [
375
            'action' => 'query',
376
            'meta' => 'siteinfo',
377
            'siprop' => 'extensions',
378
            'format' => 'json',
379
        ]])->getBody()->getContents(), true);
380
381
        $extensions = $res['query']['extensions'] ?? [];
382
        return array_map(function ($extension) {
383
            return $extension['name'];
384
        }, $extensions);
385
    }
386
387
    /**
388
     * Get a list of users who are in one of the given user groups.
389
     * @param Project $project
390
     * @param string[] $groups List of user groups to look for.
391
     * @param string[] $globalGroups List of global groups to look for.
392
     * @return string[] with keys 'user_name' and 'ug_group'
393
     */
394
    public function getUsersInGroups(Project $project, array $groups = [], array $globalGroups = []): array
395
    {
396
        $cacheKey = $this->getCacheKey(func_get_args(), 'project_useringroups');
397
        if ($this->cache->hasItem($cacheKey)) {
398
            return $this->cache->getItem($cacheKey)->get();
399
        }
400
401
        $userTable = $project->getTableName('user');
402
        $userGroupsTable = $project->getTableName('user_groups');
403
404
        $sql = "SELECT user_name, ug_group AS user_group
405
                FROM $userTable
406
                JOIN $userGroupsTable ON ug_user = user_id
407
                WHERE ug_group IN (?)
408
                GROUP BY user_name, ug_group";
409
        $users = $this->getProjectsConnection($project)
410
            ->executeQuery($sql, [$groups], [Connection::PARAM_STR_ARRAY])
411
            ->fetchAllAssociative();
412
413
        if (count($globalGroups) > 0 && $this->isWMF) {
414
            $sql = "SELECT gu_name AS user_name, gug_group AS user_group
415
                    FROM centralauth_p.global_user_groups
416
                    JOIN centralauth_p.globaluser ON gug_user = gu_id
417
                    WHERE gug_group IN (?)
418
                    GROUP BY user_name, user_group";
419
            $globalUsers = $this->getProjectsConnection('centralauth')
420
                ->executeQuery($sql, [$globalGroups], [Connection::PARAM_STR_ARRAY])
421
                ->fetchAllAssociative();
422
423
            $users = array_merge($users, $globalUsers);
424
        }
425
426
        // Cache for 12 hours and return.
427
        return $this->setCache($cacheKey, $users, 'PT12H');
428
    }
429
}
430