ProjectRepository::getDefaultProject()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
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 Doctrine\Persistence\ManagerRegistry;
11
use Exception;
12
use GuzzleHttp\Client;
13
use Psr\Cache\CacheItemPoolInterface;
14
use Psr\Log\LoggerInterface;
15
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
16
17
/**
18
 * This class provides data to the Project class.
19
 * @codeCoverageIgnore
20
 */
21
class ProjectRepository extends Repository
22
{
23
    protected PageAssessmentsRepository $assessmentsRepo;
24
25
    /** @var string[] Basic metadata if XTools is in single-wiki mode. */
26
    protected array $singleBasicInfo;
27
28
    /** @var string The cache key for the 'all project' metadata. */
29
    protected string $cacheKeyAllProjects = 'allprojects';
30
31
    /** @var string The configured default project. */
32
    protected string $defaultProject;
33
34
    /** @var bool Whether XTools is configured to run on a single wiki or not. */
35
    protected bool $singleWiki;
36
37
    /** @var array Projects that have opted into showing restricted stats to everyone. */
38
    protected array $optedIn;
39
40
    /** @var string The project's API path. */
41
    protected string $apiPath;
42
43
    /**
44
     * @param ManagerRegistry $managerRegistry
45
     * @param CacheItemPoolInterface $cache
46
     * @param Client $guzzle
47
     * @param LoggerInterface $logger
48
     * @param ParameterBagInterface $parameterBag
49
     * @param bool $isWMF
50
     * @param int $queryTimeout
51
     * @param PageAssessmentsRepository $assessmentsRepo
52
     * @param string $defaultProject
53
     * @param bool $singleWiki
54
     * @param array $optedIn
55
     * @param string $apiPath
56
     */
57
    public function __construct(
58
        ManagerRegistry $managerRegistry,
59
        CacheItemPoolInterface $cache,
60
        Client $guzzle,
61
        LoggerInterface $logger,
62
        ParameterBagInterface $parameterBag,
63
        bool $isWMF,
64
        int $queryTimeout,
65
        PageAssessmentsRepository $assessmentsRepo,
66
        string $defaultProject,
67
        bool $singleWiki,
68
        array $optedIn,
69
        string $apiPath
70
    ) {
71
        $this->assessmentsRepo = $assessmentsRepo;
72
        $this->defaultProject = $defaultProject;
73
        $this->singleWiki = $singleWiki;
74
        $this->optedIn = $optedIn;
75
        $this->apiPath = $apiPath;
76
        parent::__construct($managerRegistry, $cache, $guzzle, $logger, $parameterBag, $isWMF, $queryTimeout);
77
    }
78
79
    /**
80
     * Convenience method to get a new Project object based on a given identification string.
81
     * @param string $projectIdent The domain name, database name, or URL of a project.
82
     * @return Project
83
     */
84
    public function getProject(string $projectIdent): Project
85
    {
86
        $project = new Project($projectIdent);
87
        $project->setRepository($this);
88
        $project->setPageAssessments(new PageAssessments($this->assessmentsRepo, $project));
89
90
        if ($this->singleWiki) {
91
            $this->setSingleBasicInfo([
92
                'url' => $this->parameterBag->get('wiki_url'),
93
                'dbName' => '', // Just so this will pass in CI.
94
                // TODO: this will need to be restored for third party support; KEYWORD: isWMF
95
                // 'dbName' => $this->parameterBag->('database_replica_name'),
96
            ]);
97
        }
98
99
        return $project;
100
    }
101
102
    /**
103
     * Get the XTools default project.
104
     * @return Project
105
     */
106
    public function getDefaultProject(): Project
107
    {
108
        return $this->getProject($this->defaultProject);
109
    }
110
111
    /**
112
     * Get the global 'meta' project, which is either Meta (if this is Labs) or the default project.
113
     * @return Project
114
     */
115
    public function getGlobalProject(): Project
116
    {
117
        if ($this->isWMF) {
118
            return $this->getProject('metawiki');
119
        } else {
120
            return $this->getDefaultProject();
121
        }
122
    }
123
124
    /**
125
     * For single-wiki installations, you must manually set the wiki URL and database name
126
     * (because there's no meta.wiki database to query).
127
     * @param array $metadata
128
     * @throws Exception
129
     */
130
    public function setSingleBasicInfo(array $metadata): void
131
    {
132
        if (!array_key_exists('url', $metadata) || !array_key_exists('dbName', $metadata)) {
133
            $error = "Single-wiki metadata should contain 'url', 'dbName' and 'lang' keys.";
134
            throw new Exception($error);
135
        }
136
        $this->singleBasicInfo = array_intersect_key($metadata, [
137
            'url' => '',
138
            'dbName' => '',
139
            'lang' => '',
140
        ]);
141
    }
142
143
    /**
144
     * Get the 'dbName', 'url' and 'lang' of all projects.
145
     * @return string[][] Each item has 'dbName', 'url' and 'lang' keys.
146
     */
147
    public function getAll(): array
148
    {
149
        $this->logger->debug(__METHOD__." Getting all projects' metadata");
150
        // Single wiki mode?
151
        if (!empty($this->singleBasicInfo)) {
152
            return [$this->getOne('')];
153
        }
154
155
        // Maybe we've already fetched it.
156
        if ($this->cache->hasItem($this->cacheKeyAllProjects)) {
157
            return $this->cache->getItem($this->cacheKeyAllProjects)->get();
158
        }
159
160
        if ($this->parameterBag->has("database_meta_table")) {
161
            $table = $this->parameterBag->get('database_meta_name') . '.' .
162
                $this->parameterBag->get('database_meta_table');
163
        } else {
164
            $table = "meta_p.wiki";
165
        }
166
167
        // Otherwise, fetch all from the database.
168
        $sql = "SELECT dbname AS dbName, url, lang FROM $table";
169
        $projects = $this->executeProjectsQuery('meta', $sql)
170
            ->fetchAllAssociative();
171
        $projectsMetadata = [];
172
        foreach ($projects as $project) {
173
            $projectsMetadata[$project['dbName']] = $project;
174
        }
175
176
        // Cache for one day and return.
177
        return $this->setCache(
178
            $this->cacheKeyAllProjects,
179
            $projectsMetadata,
180
            'P1D'
181
        );
182
    }
183
184
    /**
185
     * Get the 'dbName', 'url' and 'lang' of a project. This is all you need to make database queries.
186
     * More comprehensive metadata can be fetched with getMetadata() at the expense of an API call.
187
     * @param string $project A project URL, domain name, or database name.
188
     * @return string[]|null With 'dbName', 'url' and 'lang' keys; or null if not found.
189
     */
190
    public function getOne(string $project): ?array
191
    {
192
        $this->logger->debug(__METHOD__." Getting metadata about $project");
193
        // For single-wiki setups, every project is the same.
194
        if (isset($this->singleBasicInfo)) {
195
            return $this->singleBasicInfo;
196
        }
197
198
        // Remove _p suffix.
199
        $project = rtrim($project, '_p');
200
201
        // For multi-wiki setups, first check the cache.
202
        // First the all-projects cache, then the individual one.
203
        if ($this->cache->hasItem($this->cacheKeyAllProjects)) {
204
            foreach ($this->cache->getItem($this->cacheKeyAllProjects)->get() as $projMetadata) {
205
                if ($projMetadata['dbName'] == "$project"
206
                    || $projMetadata['url'] == "$project"
207
                    || $projMetadata['url'] == "https://$project"
208
                    || $projMetadata['url'] == "https://$project.org"
209
                    || $projMetadata['url'] == "https://www.$project") {
210
                    $this->logger->debug(__METHOD__ . " Using cached data for $project");
211
                    return $projMetadata;
212
                }
213
            }
214
        }
215
        $cacheKey = $this->getCacheKey($project, 'project');
216
        if ($this->cache->hasItem($cacheKey)) {
217
            return $this->cache->getItem($cacheKey)->get();
218
        }
219
220
        // TODO: make this configurable if XTools is to work on 3rd party wiki farms
221
        $table = "meta_p.wiki";
222
223
        // Otherwise, fetch the project's metadata from the meta.wiki table.
224
        $sql = "SELECT dbname AS dbName, url, lang
225
                FROM $table
226
                WHERE dbname = :project
227
                    OR url LIKE :projectUrl
228
                    OR url LIKE :projectUrl2
229
                    OR url LIKE :projectUrl3
230
                    OR url LIKE :projectUrl4";
231
        $basicInfo = $this->executeProjectsQuery('meta', $sql, [
232
            'project' => $project,
233
            'projectUrl' => "https://$project",
234
            'projectUrl2' => "https://$project.org",
235
            'projectUrl3' => "https://www.$project",
236
            'projectUrl4' => "https://www.$project.org",
237
        ])->fetchAssociative();
238
        $basicInfo = false === $basicInfo ? null : $basicInfo;
239
240
        // Cache for one hour and return.
241
        return $this->setCache($cacheKey, $basicInfo, 'PT1H');
242
    }
243
244
    /**
245
     * Get metadata about a project, including the 'dbName', 'url' and 'lang'
246
     *
247
     * @param string $projectUrl The project's URL.
248
     * @return array|null With 'dbName', 'url', 'lang', 'general' and 'namespaces' keys.
249
     *   'general' contains: 'wikiName', 'articlePath', 'scriptPath', 'script',
250
     *   'timezone', and 'timezoneOffset'; 'namespaces' contains all namespace
251
     *   names, keyed by their IDs. If this function returns null, the API call
252
     *   failed.
253
     */
254
    public function getMetadata(string $projectUrl): ?array
255
    {
256
        $cacheKey = $this->getCacheKey(func_get_args(), "project_metadata");
257
        if ($this->cache->hasItem($cacheKey)) {
258
            return $this->cache->getItem($cacheKey)->get();
259
        }
260
261
        try {
262
            $res = json_decode($this->guzzle->request('GET', $projectUrl.$this->getApiPath(), [
263
                'query' => [
264
                    'action' => 'query',
265
                    'meta' => 'siteinfo',
266
                    'siprop' => 'general|namespaces',
267
                    'format' => 'json',
268
                ],
269
            ])->getBody()->getContents(), true);
270
        } catch (Exception $e) {
271
            return null;
272
        }
273
274
        $metadata = [
275
            'general' => [],
276
            'namespaces' => [],
277
        ];
278
279
        if (isset($res['query']['general'])) {
280
            $info = $res['query']['general'];
281
282
            $metadata['dbName'] = $info['wikiid'];
283
            $metadata['url'] = $info['server'];
284
            $metadata['lang'] = $info['lang'];
285
286
            $metadata['general'] = [
287
                'wikiName' => $info['sitename'],
288
                'articlePath' => $info['articlepath'],
289
                'scriptPath' => $info['scriptpath'],
290
                'script' => $info['script'],
291
                'timezone' => $info['timezone'],
292
                'timeOffset' => $info['timeoffset'],
293
                'mainpage' => $info['mainpage'],
294
            ];
295
        }
296
297
        $this->setNamespaces($res, $metadata);
298
299
        // Cache for one hour and return.
300
        return $this->setCache($cacheKey, $metadata, 'PT1H');
301
    }
302
303
    /**
304
     * Set the namespaces on the given $metadata.
305
     * @param array $res As produced by meta=siteinfo API.
306
     * @param array &$metadata The metadata array to modify.
307
     */
308
    private function setNamespaces(array $res, array &$metadata): void
309
    {
310
        if (!isset($res['query']['namespaces'])) {
311
            return;
312
        }
313
314
        foreach ($res['query']['namespaces'] as $namespace) {
315
            if ($namespace['id'] < 0) {
316
                continue;
317
            }
318
319
            if (isset($namespace['name'])) {
320
                $name = $namespace['name'];
321
            } elseif (isset($namespace['*'])) {
322
                $name = $namespace['*'];
323
            } else {
324
                continue;
325
            }
326
327
            $metadata['namespaces'][$namespace['id']] = $name;
328
        }
329
    }
330
331
    /**
332
     * Get a list of projects that have opted in to having all their users' restricted statistics available to anyone.
333
     * @return string[]
334
     */
335
    public function optedIn(): array
336
    {
337
        return $this->optedIn;
338
    }
339
340
    /**
341
     * The path to api.php.
342
     * @return string
343
     */
344
    public function getApiPath(): string
345
    {
346
        return $this->apiPath;
347
    }
348
349
    /**
350
     * Check to see if a page exists on this project and has some content.
351
     * @param Project $project The project.
352
     * @param int $namespaceId The page namespace ID.
353
     * @param string $pageTitle The page title, without namespace.
354
     * @return bool
355
     */
356
    public function pageHasContent(Project $project, int $namespaceId, string $pageTitle): bool
357
    {
358
        $pageTable = $this->getTableName($project->getDatabaseName(), 'page');
359
        $query = "SELECT page_id "
360
             . " FROM $pageTable "
361
             . " WHERE page_namespace = :ns AND page_title = :title AND page_len > 0 "
362
             . " LIMIT 1";
363
        $params = [
364
            'ns' => $namespaceId,
365
            'title' => str_replace(' ', '_', $pageTitle),
366
        ];
367
        $pages = $this->executeProjectsQuery($project, $query, $params)
368
            ->fetchAllAssociative();
369
        return count($pages) > 0;
370
    }
371
372
    /**
373
     * Get a list of the extensions installed on the wiki.
374
     * @param Project $project
375
     * @return string[]
376
     */
377
    public function getInstalledExtensions(Project $project): array
378
    {
379
        $res = json_decode($this->guzzle->request('GET', $project->getApiUrl(), ['query' => [
380
            'action' => 'query',
381
            'meta' => 'siteinfo',
382
            'siprop' => 'extensions',
383
            'format' => 'json',
384
        ]])->getBody()->getContents(), true);
385
386
        $extensions = $res['query']['extensions'] ?? [];
387
        return array_map(function ($extension) {
388
            return $extension['name'];
389
        }, $extensions);
390
    }
391
392
    /**
393
     * Get a list of users who are in one of the given user groups.
394
     * @param Project $project
395
     * @param string[] $groups List of user groups to look for.
396
     * @param string[] $globalGroups List of global groups to look for.
397
     * @return string[] with keys 'user_name' and 'ug_group'
398
     */
399
    public function getUsersInGroups(Project $project, array $groups = [], array $globalGroups = []): array
400
    {
401
        $cacheKey = $this->getCacheKey(func_get_args(), 'project_useringroups');
402
        if ($this->cache->hasItem($cacheKey)) {
403
            return $this->cache->getItem($cacheKey)->get();
404
        }
405
406
        $userTable = $project->getTableName('user');
407
        $userGroupsTable = $project->getTableName('user_groups');
408
409
        $sql = "SELECT user_name, ug_group AS user_group
410
                FROM $userTable
411
                JOIN $userGroupsTable ON ug_user = user_id
412
                WHERE ug_group IN (?)
413
                GROUP BY user_name, ug_group";
414
        $users = $this->getProjectsConnection($project)
415
            ->executeQuery($sql, [$groups], [Connection::PARAM_STR_ARRAY])
416
            ->fetchAllAssociative();
417
418
        if (count($globalGroups) > 0 && $this->isWMF) {
419
            $sql = "SELECT gu_name AS user_name, gug_group AS user_group
420
                    FROM centralauth_p.global_user_groups
421
                    JOIN centralauth_p.globaluser ON gug_user = gu_id
422
                    WHERE gug_group IN (?)
423
                    GROUP BY user_name, user_group";
424
            $globalUsers = $this->getProjectsConnection('centralauth')
425
                ->executeQuery($sql, [$globalGroups], [Connection::PARAM_STR_ARRAY])
426
                ->fetchAllAssociative();
427
428
            $users = array_merge($users, $globalUsers);
429
        }
430
431
        // Cache for 12 hours and return.
432
        return $this->setCache($cacheKey, $users, 'PT12H');
433
    }
434
}
435