Passed
Push — master ( a1ed3f...a64a0e )
by
unknown
05:21
created

ArticleInfoController::getApiHtmlResponse()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
nc 1
nop 3
dl 0
loc 16
ccs 0
cts 0
cp 0
crap 2
rs 9.7333
c 0
b 0
f 0
1
<?php
2
/**
3
 * This file contains only the ArticleInfoController class.
4
 */
5
6
namespace AppBundle\Controller;
7
8
use AppBundle\Helper\I18nHelper;
9
use DateTime;
10
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
11
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
12
use Symfony\Component\HttpFoundation\JsonResponse;
13
use Symfony\Component\HttpFoundation\RedirectResponse;
14
use Symfony\Component\HttpFoundation\Request;
15
use Symfony\Component\HttpFoundation\Response;
16
use Symfony\Component\Process\Process;
17
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
18
use Xtools\ArticleInfo;
19
use Xtools\ArticleInfoRepository;
20
use Xtools\Page;
21
use Xtools\PageAssessments;
22
use Xtools\Project;
23
use Xtools\ProjectRepository;
24
25
/**
26
 * This controller serves the search form and results for the ArticleInfo tool
27
 */
28
class ArticleInfoController extends XtoolsController
29
{
30
    /**
31
     * Get the name of the tool's index route.
32
     * This is also the name of the associated model.
33
     * @return string
34
     * @codeCoverageIgnore
35
     */
36
    public function getIndexRoute()
37
    {
38
        return 'ArticleInfo';
39
    }
40
41
    /**
42
     * The search form.
43
     * @Route("/articleinfo", name="ArticleInfo")
44
     * @Route("/articleinfo/", name="articleInfoSlash")
45
     * @Route("/articleinfo/index.php", name="articleInfoIndexPhp")
46
     * @Route("/articleinfo/{project}", name="ArticleInfoProject")
47
     * @param Request $request The HTTP request.
48
     * @return Response
49
     */
50 1
    public function indexAction(Request $request)
51
    {
52 1
        $params = $this->parseQueryParams($request);
53
54 1
        if (isset($params['project']) && isset($params['article'])) {
55
            return $this->redirectToRoute('ArticleInfoResult', $params);
56
        }
57
58
        // Convert the given project (or default project) into a Project instance.
59 1
        $params['project'] = $this->getProjectFromQuery($params);
60
61 1
        return $this->render('articleInfo/index.html.twig', [
62 1
            'xtPage' => 'articleinfo',
63 1
            'xtPageTitle' => 'tool-articleinfo',
64 1
            'xtSubtitle' => 'tool-articleinfo-desc',
65 1
            'project' => $params['project'],
66
        ]);
67
    }
68
69
    /**
70
     * Generate ArticleInfo gadget script for use on-wiki. This automatically points the
71
     * script to this installation's API. Pass ?uglify=1 to uglify the code.
72
     *
73
     * @Route("/articleinfo-gadget.js", name="ArticleInfoGadget")
74
     * @link https://www.mediawiki.org/wiki/XTools#ArticleInfo_gadget
75
     *
76
     * @param Request $request The HTTP request
77
     * @return Response
78
     * @codeCoverageIgnore
79
     */
80
    public function gadgetAction(Request $request)
81
    {
82
        $rendered = $this->renderView('articleInfo/articleinfo.js.twig');
83
84
        // SUPER hacky, but it works and is safe.
85
        if ($request->query->get('uglify') != '') {
86
            // $ and " need to be escaped.
87
            $rendered = str_replace('$', '\$', trim($rendered));
88
            $rendered = str_replace('"', '\"', trim($rendered));
89
90
            // Uglify temporary file.
91
            $tmpFile = sys_get_temp_dir() . '/xtools_articleinfo_gadget.js';
92
            $script = "echo \"$rendered\" | tee $tmpFile >/dev/null && ";
93
            $script .= $this->get('kernel')->getRootDir() .
94
                "/Resources/node_modules/uglify-es/bin/uglifyjs $tmpFile --mangle " .
95
                "&& rm $tmpFile >/dev/null";
96
            $process = new Process($script);
97
            $process->run();
98
99
            // Check for errors.
100
            $errorOutput = $process->getErrorOutput();
101
            if ($errorOutput != '') {
102
                $response = new \Symfony\Component\HttpFoundation\Response(
103
                    "Error generating uglified JS. The server said:\n\n$errorOutput"
104
                );
105
                return $response;
106
            }
107
108
            // Remove escaping.
109
            $rendered = str_replace('\$', '$', trim($process->getOutput()));
110
            $rendered = str_replace('\"', '"', trim($rendered));
111
112
            // Add comment after uglifying since it removes comments.
113
            $rendered = "/**\n * This code was automatically generated and should not " .
114
                "be manually edited.\n * For updates, please copy and paste from " .
115
                $this->generateUrl('ArticleInfoGadget', ['uglify' => 1], UrlGeneratorInterface::ABSOLUTE_URL) .
116
                "\n * Released under GPL v3 license.\n */\n" . $rendered;
117
        }
118
119
        $response = new \Symfony\Component\HttpFoundation\Response($rendered);
120
        $response->headers->set('Content-Type', 'text/javascript');
121
        return $response;
122
    }
123
124
    /**
125
     * Display the results in given date range.
126
     * @Route(
127
     *    "/articleinfo/{project}/{article}/{start}/{end}", name="ArticleInfoResult",
128
     *     requirements={
129
     *         "article"=".+",
130
     *         "start"="|\d{4}-\d{2}-\d{2}",
131
     *         "end"="|\d{4}-\d{2}-\d{2}",
132
     *     }
133
     * )
134
     * @param Request $request
135
     * @param $article
136
     * @param null|string $start
137
     * @param null|string $end
138
     * @return Response
139
     * @codeCoverageIgnore
140
     */
141
    public function resultAction(Request $request, $article, $start = null, $end = null)
142
    {
143
        // This is some complicated stuff here. We pass $start and $end to method signature
144
        // for router regex parser to parse `article` with those parameters and then
145
        // manually retrieve what we want. It's done this way because programmatical way
146
        // is much easier (or maybe even only existing) solution for that.
147
148
        // Does path have `start` and `end` parameters (even empty ones)?
149
        if (1 === preg_match('/(.+?)\/(|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?$/', $article, $matches)) {
150
            $article = $matches[1];
151
            $start = $matches[2];
152
            $end = isset($matches[3]) ? $matches[3] : null;
153
        }
154
155
        list($start, $end) = $this->getUTCFromDateParams($start, $end, false);
156
157
        // In this case only the project is validated.
158
        $ret = $this->validateProjectAndUser($request);
159
        if ($ret instanceof RedirectResponse) {
0 ignored issues
show
introduced by
$ret is always a sub-type of Symfony\Component\HttpFoundation\RedirectResponse.
Loading history...
160
            return $ret;
161
        } else {
162
            $project = $ret[0];
163
        }
164
165
        $page = $this->getAndValidatePage($project, $article);
166
        if ($page instanceof RedirectResponse) {
167
            return $page;
168
        }
169
170
        if (!$this->isDateRangeValid($page, $start, $end)) {
171
            $this->addFlash('notice', ['date-range-outside-revisions']);
172
173
            return $this->redirectToRoute('ArticleInfoResult', [
174
                'project' => $request->get('project'),
175
                'article' => $article
176
            ]);
177
        }
178
179
        $articleInfoRepo = new ArticleInfoRepository();
180
        $articleInfoRepo->setContainer($this->container);
181
        $articleInfo = new ArticleInfo($page, $this->container, $start, $end);
182
        $articleInfo->setRepository($articleInfoRepo);
183
        $articleInfo->setI18nHelper($this->container->get('app.i18n_helper'));
184
185
        $articleInfo->prepareData();
186
187
        $maxRevisions = $this->container->getParameter('app.max_page_revisions');
188
189
        // Show message if we hit the max revisions.
190
        if ($articleInfo->tooManyRevisions()) {
191
            // FIXME: i18n number_format?
192
            $this->addFlash('notice', ['too-many-revisions', number_format($maxRevisions), $maxRevisions]);
193
        }
194
195
        // For when there is very old data (2001 era) which may cause miscalculations.
196
        if ($articleInfo->getFirstEdit()->getYear() < 2003) {
197
            $this->addFlash('warning', ['old-page-notice']);
198
        }
199
200
        $ret = [
201
            'xtPage' => 'articleinfo',
202
            'xtTitle' => $page->getTitle(),
203
            'project' => $project,
204
            'editorlimit' => $request->query->get('editorlimit', 20),
205
            'botlimit' => $request->query->get('botlimit', 10),
206
            'pageviewsOffset' => 60,
207
            'ai' => $articleInfo,
208
            'page' => $page,
209
        ];
210
211
        // Output the relevant format template.
212
        $format = $request->query->get('format', 'html');
213
        if ($format == '') {
214
            // The default above doesn't work when the 'format' parameter is blank.
215
            $format = 'html';
216
        }
217
        $response = $this->render("articleInfo/result.$format.twig", $ret);
218
        if ($format == 'wikitext') {
219
            $response->headers->set('Content-Type', 'text/plain');
220
        }
221
222
        return $response;
223
    }
224
225
    /**
226
     * Check if there were any revisions of given page in given date range.
227
     * @param Page $page
228
     * @param false|int $start
229
     * @param false|int $end
230
     * @return bool
231
     */
232
    private function isDateRangeValid(Page $page, $start, $end)
233
    {
234
        return $page->getNumRevisions(null, $start, $end) > 0;
235
    }
236
237
    /**
238
     * Get textshares information about the article.
239
     * @Route(
240
     *     "/articleinfo-authorship/{project}/{article}",
241
     *     name="ArticleInfoAuthorshipResult",
242
     *     requirements={"article"=".+"}
243
     * )
244
     * @param Request $request The HTTP request.
245
     * @param string $article
246
     * @return Response
247
     * @codeCoverageIgnore
248
     */
249
    public function textsharesResultAction(Request $request, $article)
250
    {
251
        // In this case only the project is validated.
252
        $ret = $this->validateProjectAndUser($request);
253
        if ($ret instanceof RedirectResponse) {
0 ignored issues
show
introduced by
$ret is always a sub-type of Symfony\Component\HttpFoundation\RedirectResponse.
Loading history...
254
            return $ret;
255
        } else {
256
            $project = $ret[0];
257
        }
258
259
        $page = $this->getAndValidatePage($project, $article);
260
        if ($page instanceof RedirectResponse) {
261
            return $page;
262
        }
263
264
        $articleInfoRepo = new ArticleInfoRepository();
265
        $articleInfoRepo->setContainer($this->container);
266
        $articleInfo = new ArticleInfo($page, $this->container);
267
        $articleInfo->setRepository($articleInfoRepo);
268
269
        $isSubRequest = $request->get('htmlonly')
270
            || $this->get('request_stack')->getParentRequest() !== null;
271
272
        $limit = $isSubRequest ? 10 : null;
273
274
        return $this->render('articleInfo/textshares.html.twig', [
275
            'xtPage' => 'articleinfo',
276
            'xtTitle' => $page->getTitle(),
277
            'project' => $project,
278
            'page' => $page,
279
            'textshares' => $articleInfo->getTextshares($limit),
280
            'is_sub_request' => $isSubRequest,
281
        ]);
282
    }
283
284
    /************************ API endpoints ************************/
285
286
    /**
287
     * Get basic info on a given article.
288
     * @Route(
289
     *     "/api/articleinfo/{project}/{article}",
290
     *     name="ArticleInfoApiAction",
291
     *     requirements={"article"=".+"}
292
     * )
293
     * @Route("/api/page/articleinfo/{project}/{article}", requirements={"article"=".+"})
294
     * @param Request $request The HTTP request.
295
     * @param string $project
296
     * @param string $article
297
     * @return View
0 ignored issues
show
Bug introduced by
The type AppBundle\Controller\View was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
298
     * See ArticleInfoControllerTest::testArticleInfoApi()
299
     * @codeCoverageIgnore
300
     */
301
    public function articleInfoApiAction(Request $request, $project, $article)
302
    {
303
        $projectData = ProjectRepository::getProject($project, $this->container);
304
        if (!$projectData->exists()) {
305
            return new JsonResponse(
306
                ['error' => "$project is not a valid project"],
307
                Response::HTTP_NOT_FOUND
308
            );
309
        }
310
311
        $page = $this->getAndValidatePage($projectData, $article);
312
        if ($page instanceof RedirectResponse) {
313
            return new JsonResponse(
314
                ['error' => "$article was not found"],
315
                Response::HTTP_NOT_FOUND
316
            );
317
        }
318
319
        $data = $this->getArticleInfoApiData($projectData, $page);
320
321
        if ($request->query->get('format') === 'html') {
322
            return $this->getApiHtmlResponse($projectData, $page, $data);
323
        }
324
325
        $body = array_merge([
326
            'project' => $projectData->getDomain(),
327
            'page' => $page->getTitle(),
328
        ], $data);
329
330
        return new JsonResponse(
331
            $body,
332
            Response::HTTP_OK
333
        );
334
    }
335
336
    /**
337
     * Generate the data structure that will used in the ArticleInfo API response.
338
     * @param  Project $project
339
     * @param  Page    $page
340
     * @return array
341
     * @codeCoverageIgnore
342
     */
343
    private function getArticleInfoApiData(Project $project, Page $page)
344
    {
345
        /** @var integer Number of days to query for pageviews */
346
        $pageviewsOffset = 30;
347
348
        $data = [
349
            'project' => $project->getDomain(),
350
            'page' => $page->getTitle(),
351
            'watchers' => (int) $page->getWatchers(),
352
            'pageviews' => $page->getLastPageviews($pageviewsOffset),
353
            'pageviews_offset' => $pageviewsOffset,
354
        ];
355
356
        $info = false;
0 ignored issues
show
Unused Code introduced by
The assignment to $info is dead and can be removed.
Loading history...
357
358
        try {
359
            $info = $page->getBasicEditingInfo();
360
        } catch (\Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException $e) {
361
            // No more open database connections.
362
            $data['error'] = 'Unable to fetch revision data. Please try again later.';
363
        } catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
364
            /**
365
             * The query most likely exceeded the maximum query time,
366
             * so we'll abort and give only info retrived by the API.
367
             */
368
            $data['error'] = 'Unable to fetch revision data. The query may have timed out.';
369
        }
370
371
        if ($info != false) {
372
            $creationDateTime = DateTime::createFromFormat('YmdHis', $info['created_at']);
373
            $modifiedDateTime = DateTime::createFromFormat('YmdHis', $info['modified_at']);
374
            $secsSinceLastEdit = (new DateTime)->getTimestamp() - $modifiedDateTime->getTimestamp();
375
376
            $assessment = $page->getProject()
377
                ->getPageAssessments()
378
                ->getAssessment($page);
379
380
            $data = array_merge($data, [
381
                'revisions' => (int) $info['num_edits'],
382
                'editors' => (int) $info['num_editors'],
383
                'author' => $info['author'],
384
                'author_editcount' => (int) $info['author_editcount'],
385
                'created_at' => $creationDateTime->format('Y-m-d'),
386
                'created_rev_id' => $info['created_rev_id'],
387
                'modified_at' => $modifiedDateTime->format('Y-m-d H:i'),
388
                'secs_since_last_edit' => $secsSinceLastEdit,
389
                'last_edit_id' => (int) $info['modified_rev_id'],
390
                'assessment' => $assessment,
391
            ]);
392
        }
393
394
        return $data;
395
    }
396
397
    /**
398
     * Get the Response for the HTML output of the ArticleInfo API action.
399
     * @param  Project  $project
400
     * @param  Page     $page
401
     * @param  string[] $data The pre-fetched data.
402
     * @return Response
403
     * @codeCoverageIgnore
404
     */
405
    private function getApiHtmlResponse(Project $project, Page $page, $data)
406
    {
407
        $response = $this->render('articleInfo/api.html.twig', [
408
            'data' => $data,
409
            'project' => $project,
410
            'page' => $page,
411
        ]);
412
413
        // All /api routes by default respond with a JSON content type.
414
        $response->headers->set('Content-Type', 'text/html');
415
416
        // This endpoint is hit constantly and user could be browsing the same page over
417
        // and over (popular noticeboard, for instance), so offload brief caching to browser.
418
        $response->setClientTtl(350);
419
420
        return $response;
421
    }
422
423
    /**
424
     * Get prose statistics for the given article.
425
     * @Route(
426
     *     "/api/page/prose/{project}/{article}",
427
     *     name="PageApiProse",
428
     *     requirements={"article"=".+"}
429
     * )
430
     * @param Request $request The HTTP request.
431
     * @param string $article
432
     * @return JsonResponse
433
     * @codeCoverageIgnore
434
     */
435
    public function proseStatsApiAction(Request $request, $article)
436
    {
437
        $this->recordApiUsage('page/prose');
438
439
        // In this case only the project is validated.
440
        $ret = $this->validateProjectAndUser($request);
441
        if ($ret instanceof RedirectResponse) {
0 ignored issues
show
introduced by
$ret is always a sub-type of Symfony\Component\HttpFoundation\RedirectResponse.
Loading history...
442
            return $ret;
443
        } else {
444
            $project = $ret[0];
445
        }
446
447
        $page = $this->getAndValidatePage($project, $article);
448
        if ($page instanceof RedirectResponse) {
449
            return new JsonResponse(
450
                ['error' => "$article was not found"],
451
                Response::HTTP_NOT_FOUND
452
            );
453
        }
454
455
        $articleInfoRepo = new ArticleInfoRepository();
456
        $articleInfoRepo->setContainer($this->container);
457
        $articleInfo = new ArticleInfo($page, $this->container);
458
        $articleInfo->setRepository($articleInfoRepo);
459
460
        $ret = array_merge(
461
            [
462
                'project' => $project->getDomain(),
463
                'page' => $page->getTitle(),
464
            ],
465
            $articleInfo->getProseStats()
466
        );
467
468
        return new JsonResponse(
469
            $ret,
470
            Response::HTTP_OK
471
        );
472
    }
473
474
    /**
475
     * Get the page assessments of a page, along with various related metadata.
476
     * @Route(
477
     *     "/api/page/assessments/{project}/{articles}",
478
     *     name="PageApiAssessments",
479
     *     requirements={"article"=".+"}
480
     * )
481
     * @param  Request $request
482
     * @param  string $articles May be multiple pages separated by pipes, e.g. Foo|Bar|Baz
483
     * @return JsonResponse
484
     * @codeCoverageIgnore
485
     */
486
    public function assessmentsApiAction(Request $request, $articles)
487
    {
488
        $this->recordApiUsage('page/assessments');
489
490
        // First validate project.
491
        $ret = $this->validateProjectAndUser($request);
492
        if ($ret instanceof RedirectResponse) {
0 ignored issues
show
introduced by
$ret is always a sub-type of Symfony\Component\HttpFoundation\RedirectResponse.
Loading history...
493
            return new JsonResponse(
494
                ['error' => 'Invalid project'],
495
                Response::HTTP_NOT_FOUND
496
            );
497
        } else {
498
            $project = $ret[0];
499
        }
500
501
        $pages = explode('|', $articles);
502
        $out = [];
503
504
        foreach ($pages as $page) {
505
            $page = $this->getAndValidatePage($project, $page);
506
            if ($page instanceof RedirectResponse) {
507
                $out[$page->getTitle()] = false;
508
            } else {
509
                $assessments = $page->getProject()
510
                    ->getPageAssessments()
511
                    ->getAssessments($page);
512
513
                $out[$page->getTitle()] = $request->get('classonly')
514
                    ? $assessments['assessment']
515
                    : $assessments;
516
            }
517
        }
518
519
        return new JsonResponse(
520
            $out,
521
            Response::HTTP_OK
522
        );
523
    }
524
525
    /**
526
     * Get number of in and outgoing links and redirects to the given page.
527
     * @Route(
528
     *     "/api/page/links/{project}/{article}",
529
     *     name="PageApiLinks",
530
     *     requirements={"article"=".+"}
531
     * )
532
     * @param Request $request The HTTP request.
533
     * @param string $article
534
     * @return JsonResponse
535
     * @codeCoverageIgnore
536
     */
537
    public function linksApiAction(Request $request, $article)
538
    {
539
        $this->recordApiUsage('page/links');
540
541
        // First validate project.
542
        $ret = $this->validateProjectAndUser($request);
543
        if ($ret instanceof RedirectResponse) {
0 ignored issues
show
introduced by
$ret is always a sub-type of Symfony\Component\HttpFoundation\RedirectResponse.
Loading history...
544
            return new JsonResponse(
545
                ['error' => 'Invalid project'],
546
                Response::HTTP_NOT_FOUND
547
            );
548
        } else {
549
            $project = $ret[0];
550
        }
551
552
        $page = $this->getAndValidatePage($project, $article);
553
554
        $response = new JsonResponse(
555
            $page->countLinksAndRedirects(),
556
            Response::HTTP_OK
557
        );
558
        $response->setEncodingOptions(JSON_NUMERIC_CHECK);
559
560
        return $response;
561
    }
562
}
563