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

PagesController::tooHighEditCountActionAllowlist()   A

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\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
    public function __construct(
33
        RequestStack $requestStack,
34
        ContainerInterface $container,
35
        CacheItemPoolInterface $cache,
36
        Client $guzzle,
37
        I18nHelper $i18n,
38
        ProjectRepository $projectRepo,
39
        UserRepository $userRepo,
40
        PageRepository $pageRepo,
41
        PagesRepository $pagesRepo
42
    ) {
43
        $this->pagesRepo = $pagesRepo;
44
        parent::__construct($requestStack, $container, $cache, $guzzle, $i18n, $projectRepo, $userRepo, $pageRepo);
45
    }
46
47
    /**
48
     * Get the name of the tool's index route.
49
     * This is also the name of the associated model.
50
     * @return string
51
     * @codeCoverageIgnore
52
     */
53
    public function getIndexRoute(): string
54
    {
55
        return 'Pages';
56
    }
57
58
    /**
59
     * @inheritDoc
60
     */
61
    public function tooHighEditCountRoute(): string
62
    {
63
        return $this->getIndexRoute();
64
    }
65
66
    /**
67
     * @inheritDoc
68
     */
69
    public function tooHighEditCountActionAllowlist(): array
70
    {
71
        return ['countPagesApi'];
72
    }
73
74
    /**
75
     * Display the form.
76
     * @Route("/pages", name="Pages")
77
     * @Route("/pages/index.php", name="PagesIndexPhp")
78
     * @Route("/pages/{project}", name="PagesProject")
79
     * @return Response
80
     */
81
    public function indexAction(): Response
82
    {
83
        // Redirect if at minimum project and username are given.
84
        if (isset($this->params['project']) && isset($this->params['username'])) {
85
            return $this->redirectToRoute('PagesResult', $this->params);
86
        }
87
88
        // Otherwise fall through.
89
        return $this->render('pages/index.html.twig', array_merge([
90
            'xtPageTitle' => 'tool-pages',
91
            'xtSubtitle' => 'tool-pages-desc',
92
            'xtPage' => 'Pages',
93
94
            // Defaults that will get overridden if in $params.
95
            'username' => '',
96
            'namespace' => 0,
97
            'redirects' => 'noredirects',
98
            'deleted' => 'all',
99
            'start' => '',
100
            'end' => '',
101
        ], $this->params, ['project' => $this->project]));
102
    }
103
104
    /**
105
     * Every action in this controller (other than 'index') calls this first.
106
     * @param string $redirects One of 'noredirects', 'onlyredirects' or 'all' for both.
107
     * @param string $deleted One of 'live', 'deleted' or 'all' for both.
108
     * @return Pages
109
     * @codeCoverageIgnore
110
     */
111
    protected function setUpPages(string $redirects, string $deleted): Pages
112
    {
113
        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

113
        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...
114
            $this->params['username'] = $this->user->getUsername();
115
            $this->throwXtoolsException($this->getIndexRoute(), 'error-ip-range-unsupported');
116
        }
117
118
        return new Pages(
119
            $this->pagesRepo,
120
            $this->project,
121
            $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

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