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