Passed
Push — main ( ec4ebd...49d96d )
by MusikAnimal
04:21
created

PagesController   A

Complexity

Total Complexity 33

Size/Duplication

Total Lines 373
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 33
eloc 109
dl 0
loc 373
rs 9.76
c 0
b 0
f 0

12 Methods

Rating   Name   Duplication   Size   Complexity  
A resultAction() 0 31 4
A getPagesApiAction() 0 20 4
A tooHighEditCountRoute() 0 3 1
A tooHighEditCountActionAllowlist() 0 3 1
A createPagePile() 0 27 4
A getSummaryColumns() 0 27 5
A __construct() 0 13 1
A getPagepileResult() 0 21 4
A countPagesApiAction() 0 12 3
A indexAction() 0 21 3
A getIndexRoute() 0 3 1
A setUpPages() 0 17 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace App\Controller;
6
7
use App\Helper\I18nHelper;
8
use App\Model\Pages;
9
use App\Model\Project;
10
use App\Repository\PageRepository;
11
use App\Repository\PagesRepository;
12
use App\Repository\ProjectRepository;
13
use App\Repository\UserRepository;
14
use GuzzleHttp\Client;
15
use GuzzleHttp\Exception\ClientException;
16
use Psr\Cache\CacheItemPoolInterface;
17
use Symfony\Component\DependencyInjection\ContainerInterface;
18
use Symfony\Component\HttpFoundation\JsonResponse;
19
use Symfony\Component\HttpFoundation\RedirectResponse;
20
use Symfony\Component\HttpFoundation\RequestStack;
21
use Symfony\Component\HttpFoundation\Response;
22
use Symfony\Component\HttpKernel\Exception\HttpException;
23
use Symfony\Component\Routing\Annotation\Route;
24
25
/**
26
 * This controller serves the Pages tool.
27
 */
28
class PagesController extends XtoolsController
29
{
30
    protected PagesRepository $pagesRepo;
31
32
    /**
33
     * @param RequestStack $requestStack
34
     * @param ContainerInterface $container
35
     * @param CacheItemPoolInterface $cache
36
     * @param Client $guzzle
37
     * @param I18nHelper $i18n
38
     * @param ProjectRepository $projectRepo
39
     * @param UserRepository $userRepo
40
     * @param PageRepository $pageRepo
41
     * @param PagesRepository $pagesRepo
42
     * @codeCoverageIgnore
43
     */
44
    public function __construct(
45
        RequestStack $requestStack,
46
        ContainerInterface $container,
47
        CacheItemPoolInterface $cache,
48
        Client $guzzle,
49
        I18nHelper $i18n,
50
        ProjectRepository $projectRepo,
51
        UserRepository $userRepo,
52
        PageRepository $pageRepo,
53
        PagesRepository $pagesRepo
54
    ) {
55
        $this->pagesRepo = $pagesRepo;
56
        parent::__construct($requestStack, $container, $cache, $guzzle, $i18n, $projectRepo, $userRepo, $pageRepo);
57
    }
58
59
    /**
60
     * Get the name of the tool's index route.
61
     * This is also the name of the associated model.
62
     * @inheritDoc
63
     * @codeCoverageIgnore
64
     */
65
    public function getIndexRoute(): string
66
    {
67
        return 'Pages';
68
    }
69
70
    /**
71
     * @inheritDoc
72
     * @codeCoverageIgnore
73
     */
74
    public function tooHighEditCountRoute(): string
75
    {
76
        return $this->getIndexRoute();
77
    }
78
79
    /**
80
     * @inheritDoc
81
     * @codeCoverageIgnore
82
     */
83
    public function tooHighEditCountActionAllowlist(): array
84
    {
85
        return ['countPagesApi'];
86
    }
87
88
    /**
89
     * Display the form.
90
     * @Route("/pages", name="Pages")
91
     * @Route("/pages/index.php", name="PagesIndexPhp")
92
     * @Route("/pages/{project}", name="PagesProject")
93
     * @return Response
94
     */
95
    public function indexAction(): Response
96
    {
97
        // Redirect if at minimum project and username are given.
98
        if (isset($this->params['project']) && isset($this->params['username'])) {
99
            return $this->redirectToRoute('PagesResult', $this->params);
100
        }
101
102
        // Otherwise fall through.
103
        return $this->render('pages/index.html.twig', array_merge([
104
            'xtPageTitle' => 'tool-pages',
105
            'xtSubtitle' => 'tool-pages-desc',
106
            'xtPage' => 'Pages',
107
108
            // Defaults that will get overridden if in $params.
109
            'username' => '',
110
            'namespace' => 0,
111
            'redirects' => 'noredirects',
112
            'deleted' => 'all',
113
            'start' => '',
114
            'end' => '',
115
        ], $this->params, ['project' => $this->project]));
116
    }
117
118
    /**
119
     * Every action in this controller (other than 'index') calls this first.
120
     * @param string $redirects One of 'noredirects', 'onlyredirects' or 'all' for both.
121
     * @param string $deleted One of 'live', 'deleted' or 'all' for both.
122
     * @return Pages
123
     * @codeCoverageIgnore
124
     */
125
    protected function setUpPages(string $redirects, string $deleted): Pages
126
    {
127
        if ($this->user->isIpRange()) {
0 ignored issues
show
Bug introduced by
The method isIpRange() does not exist on null. ( Ignorable by Annotation )

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

127
        if ($this->user->/** @scrutinizer ignore-call */ isIpRange()) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
128
            $this->params['username'] = $this->user->getUsername();
129
            $this->throwXtoolsException($this->getIndexRoute(), 'error-ip-range-unsupported');
130
        }
131
132
        return new Pages(
133
            $this->pagesRepo,
134
            $this->project,
135
            $this->user,
0 ignored issues
show
Bug introduced by
It seems like $this->user can also be of type null; however, parameter $user of App\Model\Pages::__construct() does only seem to accept App\Model\User, maybe add an additional type check? ( Ignorable by Annotation )

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

135
            /** @scrutinizer ignore-type */ $this->user,
Loading history...
136
            $this->namespace,
137
            $redirects,
138
            $deleted,
139
            $this->start,
140
            $this->end,
141
            $this->offset
142
        );
143
    }
144
145
    /**
146
     * Display the results.
147
     * @Route(
148
     *     "/pages/{project}/{username}/{namespace}/{redirects}/{deleted}/{start}/{end}/{offset}",
149
     *     name="PagesResult",
150
     *     requirements={
151
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
152
     *         "namespace"="|all|\d+",
153
     *         "redirects"="|[^/]+",
154
     *         "deleted"="|all|live|deleted",
155
     *         "start"="|\d{4}-\d{2}-\d{2}",
156
     *         "end"="|\d{4}-\d{2}-\d{2}",
157
     *         "offset"="|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}",
158
     *     },
159
     *     defaults={
160
     *         "namespace"=0,
161
     *         "start"=false,
162
     *         "end"=false,
163
     *         "offset"=false,
164
     *     }
165
     * )
166
     * @param string $redirects One of 'noredirects', 'onlyredirects' or 'all' for both.
167
     * @param string $deleted One of 'live', 'deleted' or 'all' for both.
168
     * @return RedirectResponse|Response
169
     * @codeCoverageIgnore
170
     */
171
    public function resultAction(string $redirects = 'noredirects', string $deleted = 'all')
172
    {
173
        // Check for legacy values for 'redirects', and redirect
174
        // back with correct values if need be. This could be refactored
175
        // out to XtoolsController, but this is the only tool in the suite
176
        // that deals with redirects, so we'll keep it confined here.
177
        $validRedirects = ['', 'noredirects', 'onlyredirects', 'all'];
178
        if ('none' === $redirects || !in_array($redirects, $validRedirects)) {
179
            return $this->redirectToRoute('PagesResult', array_merge($this->params, [
180
                'redirects' => 'noredirects',
181
                'deleted' => $deleted,
182
                'offset' => $this->offset,
183
            ]));
184
        }
185
186
        $pages = $this->setUpPages($redirects, $deleted);
187
        $pages->prepareData();
188
189
        $ret = [
190
            'xtPage' => 'Pages',
191
            'xtTitle' => $this->user->getUsername(),
192
            'summaryColumns' => $this->getSummaryColumns($pages),
193
            'pages' => $pages,
194
        ];
195
196
        if ('PagePile' === $this->request->query->get('format')) {
197
            return $this->getPagepileResult($this->project, $pages);
198
        }
199
200
        // Output the relevant format template.
201
        return $this->getFormattedResponse('pages/result', $ret);
202
    }
203
204
    /**
205
     * What columns to show in namespace totals table.
206
     * @param Pages $pages The Pages instance.
207
     * @return string[]
208
     * @codeCoverageIgnore
209
     */
210
    private function getSummaryColumns(Pages $pages): array
211
    {
212
        $summaryColumns = ['namespace'];
213
        if ('deleted' === $pages->getDeleted()) {
214
            // Showing only deleted pages shows only the deleted column, as redirects are non-applicable.
215
            $summaryColumns[] = 'deleted';
216
        } elseif ('onlyredirects' == $pages->getRedirects()) {
217
            // Don't show redundant pages column if only getting data on redirects or deleted pages.
218
            $summaryColumns[] = 'redirects';
219
        } elseif ('noredirects' == $pages->getRedirects()) {
220
            // Don't show redundant redirects column if only getting data on non-redirects.
221
            $summaryColumns[] = 'pages';
222
        } else {
223
            // Order is important here.
224
            $summaryColumns[] = 'pages';
225
            $summaryColumns[] = 'redirects';
226
        }
227
228
        // Show deleted column only when both deleted and live pages are visible.
229
        if ('all' === $pages->getDeleted()) {
230
            $summaryColumns[] = 'deleted';
231
        }
232
233
        $summaryColumns[] = 'total-page-size';
234
        $summaryColumns[] = 'average-page-size';
235
236
        return $summaryColumns;
237
    }
238
239
    /**
240
     * Create a PagePile for the given pages, and get a Redirect to that PagePile.
241
     * @param Project $project
242
     * @param Pages $pages
243
     * @return RedirectResponse
244
     * @throws HttpException
245
     * @see https://pagepile.toolforge.org
246
     * @codeCoverageIgnore
247
     */
248
    private function getPagepileResult(Project $project, Pages $pages): RedirectResponse
249
    {
250
        $namespaces = $project->getNamespaces();
251
        $pageTitles = [];
252
253
        foreach (array_values($pages->getResults()) as $pagesData) {
254
            foreach ($pagesData as $page) {
255
                if (0 === (int)$page['namespace']) {
256
                    $pageTitles[] = $page['page_title'];
257
                } else {
258
                    $pageTitles[] = (
259
                        $namespaces[$page['namespace']] ?? $this->i18n->msg('unknown')
260
                    ).':'.$page['page_title'];
261
                }
262
            }
263
        }
264
265
        $pileId = $this->createPagePile($project, $pageTitles);
266
267
        return new RedirectResponse(
268
            "https://pagepile.toolforge.org/api.php?id=$pileId&action=get_data&format=html&doit1"
269
        );
270
    }
271
272
    /**
273
     * Create a PagePile with the given titles.
274
     * @param Project $project
275
     * @param string[] $pageTitles
276
     * @return int The PagePile ID.
277
     * @throws HttpException
278
     * @see https://pagepile.toolforge.org/
279
     * @codeCoverageIgnore
280
     */
281
    private function createPagePile(Project $project, array $pageTitles): int
282
    {
283
        $url = 'https://pagepile.toolforge.org/api.php';
284
285
        try {
286
            $res = $this->guzzle->request('GET', $url, ['query' => [
287
                'action' => 'create_pile_with_data',
288
                'wiki' => $project->getDatabaseName(),
289
                'data' => implode("\n", $pageTitles),
290
            ]]);
291
        } catch (ClientException $e) {
292
            throw new HttpException(
293
                414,
294
                'error-pagepile-too-large'
295
            );
296
        }
297
298
        $ret = json_decode($res->getBody()->getContents(), true);
299
300
        if (!isset($ret['status']) || 'OK' !== $ret['status']) {
301
            throw new HttpException(
302
                500,
303
                'Failed to create PagePile. There may be an issue with the PagePile API.'
304
            );
305
        }
306
307
        return $ret['pile']['id'];
308
    }
309
310
    /************************ API endpoints ************************/
311
312
    /**
313
     * Get a count of the number of pages created by a user,
314
     * including the number that have been deleted and are redirects.
315
     * @Route(
316
     *     "/api/user/pages_count/{project}/{username}/{namespace}/{redirects}/{deleted}/{start}/{end}",
317
     *     name="UserApiPagesCount",
318
     *     requirements={
319
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
320
     *         "namespace"="|\d+|all",
321
     *         "redirects"="|noredirects|onlyredirects|all",
322
     *         "deleted"="|all|live|deleted",
323
     *         "start"="|\d{4}-\d{2}-\d{2}",
324
     *         "end"="|\d{4}-\d{2}-\d{2}",
325
     *     },
326
     *     defaults={
327
     *         "namespace"=0,
328
     *         "redirects"="noredirects",
329
     *         "deleted"="all",
330
     *         "start"=false,
331
     *         "end"=false,
332
     *     }
333
     * )
334
     * @param string $redirects One of 'noredirects', 'onlyredirects' or 'all' for both.
335
     * @param string $deleted One of 'live', 'deleted' or 'all' for both.
336
     * @return JsonResponse
337
     * @codeCoverageIgnore
338
     */
339
    public function countPagesApiAction(string $redirects = 'noredirects', string $deleted = 'all'): JsonResponse
340
    {
341
        $this->recordApiUsage('user/pages_count');
342
343
        $pages = $this->setUpPages($redirects, $deleted);
344
        $counts = $pages->getCounts();
345
346
        if ('all' !== $this->namespace && isset($counts[$this->namespace])) {
347
            $counts = $counts[$this->namespace];
348
        }
349
350
        return $this->getFormattedApiResponse(['counts' => (object)$counts]);
351
    }
352
353
    /**
354
     * Get the pages created by by a user.
355
     * @Route(
356
     *     "/api/user/pages/{project}/{username}/{namespace}/{redirects}/{deleted}/{start}/{end}/{offset}",
357
     *     name="UserApiPagesCreated",
358
     *     requirements={
359
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
360
     *         "namespace"="|\d+|all",
361
     *         "redirects"="|noredirects|onlyredirects|all",
362
     *         "deleted"="|all|live|deleted",
363
     *         "start"="|\d{4}-\d{2}-\d{2}",
364
     *         "end"="|\d{4}-\d{2}-\d{2}",
365
     *         "offset"="|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}",
366
     *     },
367
     *     defaults={
368
     *         "namespace"=0,
369
     *         "redirects"="noredirects",
370
     *         "deleted"="all",
371
     *         "start"=false,
372
     *         "end"=false,
373
     *         "offset"=false,
374
     *     }
375
     * )
376
     * @param string $redirects One of 'noredirects', 'onlyredirects' or 'all' for both.
377
     * @param string $deleted One of 'live', 'deleted' or blank for both.
378
     * @return JsonResponse
379
     * @codeCoverageIgnore
380
     */
381
    public function getPagesApiAction(string $redirects = 'noredirects', string $deleted = 'all'): JsonResponse
382
    {
383
        $this->recordApiUsage('user/pages');
384
385
        $pages = $this->setUpPages($redirects, $deleted);
386
        $pagesList = $pages->getResults();
387
388
        if ('all' !== $this->namespace && isset($pagesList[$this->namespace])) {
389
            $pagesList = $pagesList[$this->namespace];
390
        }
391
392
        $ret = [
393
            'pages' => (object)$pagesList,
394
        ];
395
396
        if ($pages->getNumResults() === $pages->resultsPerPage()) {
397
            $ret['continue'] = $pages->getLastTimestamp();
398
        }
399
400
        return $this->getFormattedApiResponse($ret);
401
    }
402
}
403