Passed
Push — master ( 0e30e3...7b249e )
by MusikAnimal
07:20
created

ArticleInfoController::gadgetAction()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 42
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 0
Metric Value
cc 3
eloc 25
nc 3
nop 1
dl 0
loc 42
ccs 0
cts 0
cp 0
crap 12
rs 9.52
c 0
b 0
f 0
1
<?php
2
/**
3
 * This file contains only the ArticleInfoController class.
4
 */
5
6
declare(strict_types=1);
7
8
namespace AppBundle\Controller;
9
10
use AppBundle\Exception\XtoolsHttpException;
11
use AppBundle\Helper\I18nHelper;
12
use AppBundle\Model\ArticleInfo;
13
use AppBundle\Model\Authorship;
14
use AppBundle\Model\Page;
15
use AppBundle\Model\Project;
16
use AppBundle\Repository\ArticleInfoRepository;
17
use Symfony\Component\HttpFoundation\JsonResponse;
18
use Symfony\Component\HttpFoundation\Request;
19
use Symfony\Component\HttpFoundation\Response;
20
use Symfony\Component\Process\Process;
21
use Symfony\Component\Routing\Annotation\Route;
22
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
23
24
/**
25
 * This controller serves the search form and results for the ArticleInfo tool
26
 */
27
class ArticleInfoController extends XtoolsController
28
{
29
    /** @var ArticleInfo The ArticleInfo class that does all the work. */
30
    protected $articleInfo;
31
32
    /**
33
     * Get the name of the tool's index route. This is also the name of the associated model.
34
     * @return string
35
     * @codeCoverageIgnore
36
     */
37
    public function getIndexRoute(): string
38
    {
39
        return 'ArticleInfo';
40
    }
41
42
    /**
43
     * The search form.
44
     * @Route("/articleinfo", name="ArticleInfo")
45
     * @Route("/articleinfo/index.php", name="articleInfoIndexPhp")
46
     * @Route("/articleinfo/{project}", name="ArticleInfoProject")
47
     * @return Response
48
     */
49 1
    public function indexAction(): Response
50
    {
51 1
        if (isset($this->params['project']) && isset($this->params['page'])) {
52
            return $this->redirectToRoute('ArticleInfoResult', $this->params);
53
        }
54
55 1
        return $this->render('articleInfo/index.html.twig', array_merge([
56 1
            'xtPage' => 'ArticleInfo',
57 1
            'xtPageTitle' => 'tool-articleinfo',
58 1
            'xtSubtitle' => 'tool-articleinfo-desc',
59 1
            'project' => $this->project,
60
61
            // Defaults that will get overridden if in $params.
62 1
            'start' => '',
63 1
            'end' => '',
64 1
            'page' => '',
65 1
        ], $this->params, ['project' => $this->project]));
66
    }
67
68
    /**
69
     * Setup the ArticleInfo instance and its Repository.
70
     */
71
    private function setupArticleInfo(): void
72
    {
73
        if (isset($this->articleInfo)) {
74
            return;
75
        }
76
77
        $articleInfoRepo = new ArticleInfoRepository();
78
        $articleInfoRepo->setContainer($this->container);
79
        $this->articleInfo = new ArticleInfo($this->page, $this->container, $this->start, $this->end);
80
        $this->articleInfo->setRepository($articleInfoRepo);
81
        $this->articleInfo->setI18nHelper($this->container->get('app.i18n_helper'));
82
    }
83
84
    /**
85
     * Generate ArticleInfo gadget script for use on-wiki. This automatically points the
86
     * script to this installation's API. Pass ?uglify=1 to uglify the code.
87
     *
88
     * @Route("/articleinfo-gadget.js", name="ArticleInfoGadget")
89
     * @link https://www.mediawiki.org/wiki/XTools#ArticleInfo_gadget
90
     *
91
     * @param Request $request The HTTP request
92
     * @return Response
93
     * @codeCoverageIgnore
94
     */
95
    public function gadgetAction(Request $request): Response
96
    {
97
        $rendered = $this->renderView('articleInfo/articleinfo.js.twig');
98
99
        // SUPER hacky, but it works and is safe.
100
        if ('' != $request->query->get('uglify')) {
101
            // $ and " need to be escaped.
102
            $rendered = str_replace('$', '\$', trim($rendered));
103
            $rendered = str_replace('"', '\"', trim($rendered));
104
105
            // Uglify temporary file.
106
            $tmpFile = sys_get_temp_dir() . '/xtools_articleinfo_gadget.js';
107
            $script = "echo \"$rendered\" | tee $tmpFile >/dev/null && ";
108
            $script .= $this->get('kernel')->getProjectDir().
109
                "/node_modules/uglify-es/bin/uglifyjs $tmpFile --mangle " .
110
                "&& rm $tmpFile >/dev/null";
111
            $process = new Process($script);
0 ignored issues
show
Bug introduced by
$script of type string is incompatible with the type array expected by parameter $command of Symfony\Component\Process\Process::__construct(). ( Ignorable by Annotation )

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

111
            $process = new Process(/** @scrutinizer ignore-type */ $script);
Loading history...
112
            $process->run();
113
114
            // Check for errors.
115
            $errorOutput = $process->getErrorOutput();
116
            if ('' != $errorOutput) {
117
                $response = new Response(
118
                    "Error generating uglified JS. The server said:\n\n$errorOutput"
119
                );
120
                return $response;
121
            }
122
123
            // Remove escaping.
124
            $rendered = str_replace('\$', '$', trim($process->getOutput()));
125
            $rendered = str_replace('\"', '"', trim($rendered));
126
127
            // Add comment after uglifying since it removes comments.
128
            $rendered = "/**\n * This code was automatically generated and should not " .
129
                "be manually edited.\n * For updates, please copy and paste from " .
130
                $this->generateUrl('ArticleInfoGadget', ['uglify' => 1], UrlGeneratorInterface::ABSOLUTE_URL) .
131
                "\n * Released under GPL v3 license.\n */\n" . $rendered;
132
        }
133
134
        $response = new Response($rendered);
135
        $response->headers->set('Content-Type', 'text/javascript');
136
        return $response;
137
    }
138
139
    /**
140
     * Display the results in given date range.
141
     * @Route(
142
     *    "/articleinfo/{project}/{page}/{start}/{end}", name="ArticleInfoResult",
143
     *     requirements={
144
     *         "page"="(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$",
145
     *         "start"="|\d{4}-\d{2}-\d{2}",
146
     *         "end"="|\d{4}-\d{2}-\d{2}",
147
     *     },
148
     *     defaults={
149
     *         "start"=false,
150
     *         "end"=false,
151
     *     }
152
     * )
153
     * @return Response
154
     * @codeCoverageIgnore
155
     */
156
    public function resultAction(I18nHelper $i18n): Response
157
    {
158
        if (!$this->isDateRangeValid($this->page, $this->start, $this->end)) {
159
            $this->addFlashMessage('notice', 'date-range-outside-revisions');
160
161
            return $this->redirectToRoute('ArticleInfo', [
162
                'project' => $this->request->get('project'),
163
            ]);
164
        }
165
166
        $this->setupArticleInfo();
167
        $this->articleInfo->prepareData();
168
169
        $maxRevisions = $this->container->getParameter('app.max_page_revisions');
170
171
        // Show message if we hit the max revisions.
172
        if ($this->articleInfo->tooManyRevisions()) {
173
            $this->addFlashMessage('notice', 'too-many-revisions', [
174
                $i18n->numberFormat($maxRevisions),
175
                $maxRevisions,
176
            ]);
177
        }
178
179
        // For when there is very old data (2001 era) which may cause miscalculations.
180
        if ($this->articleInfo->getFirstEdit()->getYear() < 2003) {
181
            $this->addFlashMessage('warning', 'old-page-notice');
182
        }
183
184
        $ret = [
185
            'xtPage' => 'ArticleInfo',
186
            'xtTitle' => $this->page->getTitle(),
187
            'project' => $this->project,
188
            'editorlimit' => $this->request->query->get('editorlimit', 20),
189
            'botlimit' => $this->request->query->get('botlimit', 10),
190
            'pageviewsOffset' => 60,
191
            'ai' => $this->articleInfo,
192
            'showAuthorship' => Authorship::isSupportedPage($this->page),
193
        ];
194
195
        // Output the relevant format template.
196
        return $this->getFormattedResponse('articleInfo/result', $ret);
197
    }
198
199
    /**
200
     * Check if there were any revisions of given page in given date range.
201
     * @param Page $page
202
     * @param false|int $start
203
     * @param false|int $end
204
     * @return bool
205
     */
206
    private function isDateRangeValid(Page $page, $start, $end): bool
207
    {
208
        return $page->getNumRevisions(null, $start, $end) > 0;
209
    }
210
211
    /************************ API endpoints ************************/
212
213
    /**
214
     * Get basic info on a given article.
215
     * @Route(
216
     *     "/api/articleinfo/{project}/{page}",
217
     *     name="ArticleInfoApiAction",
218
     *     requirements={"page"=".+"}
219
     * )
220
     * @Route("/api/page/articleinfo/{project}/{page}", requirements={"page"=".+"})
221
     * @return Response|JsonResponse
222
     * See ArticleInfoControllerTest::testArticleInfoApi()
223
     * @codeCoverageIgnore
224
     */
225
    public function articleInfoApiAction(): Response
226
    {
227
        $this->recordApiUsage('page/articleinfo');
228
229
        $this->setupArticleInfo();
230
        $data = $this->articleInfo->getArticleInfoApiData($this->project, $this->page);
231
232
        if ('html' === $this->request->query->get('format')) {
233
            return $this->getApiHtmlResponse($this->project, $this->page, $data);
234
        }
235
236
        return $this->getFormattedApiResponse($data);
237
    }
238
239
    /**
240
     * Get the Response for the HTML output of the ArticleInfo API action.
241
     * @param Project $project
242
     * @param Page $page
243
     * @param string[] $data The pre-fetched data.
244
     * @return Response
245
     * @codeCoverageIgnore
246
     */
247
    private function getApiHtmlResponse(Project $project, Page $page, array $data): Response
248
    {
249
        $response = $this->render('articleInfo/api.html.twig', [
250
            'project' => $project,
251
            'page' => $page,
252
            'data' => $data,
253
        ]);
254
255
        // All /api routes by default respond with a JSON content type.
256
        $response->headers->set('Content-Type', 'text/html');
257
258
        // This endpoint is hit constantly and user could be browsing the same page over
259
        // and over (popular noticeboard, for instance), so offload brief caching to browser.
260
        $response->setClientTtl(350);
261
262
        return $response;
263
    }
264
265
    /**
266
     * Get prose statistics for the given article.
267
     * @Route(
268
     *     "/api/page/prose/{project}/{page}",
269
     *     name="PageApiProse",
270
     *     requirements={"page"=".+"}
271
     * )
272
     * @return JsonResponse
273
     * @codeCoverageIgnore
274
     */
275
    public function proseStatsApiAction(): JsonResponse
276
    {
277
        $this->recordApiUsage('page/prose');
278
279
        $this->setupArticleInfo();
280
        return $this->getFormattedApiResponse($this->articleInfo->getProseStats());
281
    }
282
283
    /**
284
     * Get the page assessments of one or more pages, along with various related metadata.
285
     * @Route(
286
     *     "/api/page/assessments/{project}/{pages}",
287
     *     name="PageApiAssessments",
288
     *     requirements={"pages"=".+"}
289
     * )
290
     * @param string $pages May be multiple pages separated by pipes, e.g. Foo|Bar|Baz
291
     * @return JsonResponse
292
     * @codeCoverageIgnore
293
     */
294
    public function assessmentsApiAction(string $pages): JsonResponse
295
    {
296
        $this->recordApiUsage('page/assessments');
297
298
        $pages = explode('|', $pages);
299
        $out = [];
300
301
        foreach ($pages as $pageTitle) {
302
            try {
303
                $page = $this->validatePage($pageTitle);
304
                $assessments = $page->getProject()
305
                    ->getPageAssessments()
306
                    ->getAssessments($page);
307
308
                $out[$page->getTitle()] = $this->request->get('classonly')
309
                    ? $assessments['assessment']
310
                    : $assessments;
311
            } catch (XtoolsHttpException $e) {
312
                $out[$pageTitle] = false;
313
            }
314
        }
315
316
        return $this->getFormattedApiResponse($out);
317
    }
318
319
    /**
320
     * Get number of in and outgoing links and redirects to the given page.
321
     * @Route(
322
     *     "/api/page/links/{project}/{page}",
323
     *     name="PageApiLinks",
324
     *     requirements={"page"=".+"}
325
     * )
326
     * @return JsonResponse
327
     * @codeCoverageIgnore
328
     */
329
    public function linksApiAction(): JsonResponse
330
    {
331
        $this->recordApiUsage('page/links');
332
        return $this->getFormattedApiResponse($this->page->countLinksAndRedirects());
333
    }
334
335
    /**
336
     * Get the top editors to a page.
337
     * @Route(
338
     *     "/api/page/top_editors/{project}/{page}/{start}/{end}/{limit}", name="PageApiTopEditors",
339
     *     requirements={
340
     *         "page"="(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?(?:\/(\d+))?)?$",
341
     *         "start"="|\d{4}-\d{2}-\d{2}",
342
     *         "end"="|\d{4}-\d{2}-\d{2}",
343
     *         "limit"="|\d+"
344
     *     },
345
     *     defaults={
346
     *         "start"=false,
347
     *         "end"=false,
348
     *         "limit"=20,
349
     *     }
350
     * )
351
     * @return JsonResponse
352
     * @codeCoverageIgnore
353
     */
354
    public function topEditorsApiAction(): JsonResponse
355
    {
356
        $this->recordApiUsage('page/top_editors');
357
358
        $this->setupArticleInfo();
359
        $topEditors = $this->articleInfo->getTopEditorsByEditCount(
360
            (int)$this->limit,
361
            '' != $this->request->query->get('nobots')
362
        );
363
364
        return $this->getFormattedApiResponse([
365
            'top_editors' => $topEditors,
366
        ]);
367
    }
368
}
369