Completed
Push — master ( d0838b...120b58 )
by MusikAnimal
07:49 queued 02:25
created

ArticleInfoController   A

Complexity

Total Complexity 23

Size/Duplication

Total Lines 311
Duplicated Lines 0 %

Test Coverage

Coverage 45%

Importance

Changes 0
Metric Value
dl 0
loc 311
ccs 9
cts 20
cp 0.45
rs 10
c 0
b 0
f 0
wmc 23

7 Methods

Rating   Name   Duplication   Size   Complexity  
A getToolShortname() 0 3 1
B gadgetAction() 0 42 3
A indexAction() 0 16 3
A isDateRangeValid() 0 3 1
A getApiHtmlResponse() 0 16 1
B articleInfoApiAction() 0 69 6
C resultAction() 0 76 8
1
<?php
2
/**
3
 * This file contains only the ArticleInfoController class.
4
 */
5
6
namespace AppBundle\Controller;
7
8
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
9
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
10
use Symfony\Component\HttpFoundation\Request;
11
use Symfony\Component\HttpFoundation\Response;
12
use Symfony\Component\HttpFoundation\JsonResponse;
13
use Symfony\Component\HttpFoundation\RedirectResponse;
14
use Symfony\Component\Process\Process;
15
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
16
use Xtools\ProjectRepository;
17
use Xtools\ArticleInfo;
18
use Xtools\Project;
19
use Xtools\Page;
20
use DateTime;
21
use Xtools\ArticleInfoRepository;
22
23
/**
24
 * This controller serves the search form and results for the ArticleInfo tool
25
 */
26
class ArticleInfoController extends XtoolsController
27
{
28
    /**
29
     * Get the tool's shortname.
30
     * @return string
31
     * @codeCoverageIgnore
32
     */
33
    public function getToolShortname()
34
    {
35
        return 'articleinfo';
36
    }
37
38
    /**
39
     * The search form.
40
     * @Route("/articleinfo", name="articleinfo")
41
     * @Route("/articleinfo", name="articleInfo")
42
     * @Route("/articleinfo/", name="articleInfoSlash")
43
     * @Route("/articleinfo/index.php", name="articleInfoIndexPhp")
44
     * @Route("/articleinfo/{project}", name="ArticleInfoProject")
45
     * @param Request $request The HTTP request.
46
     * @return Response
47
     */
48 1
    public function indexAction(Request $request)
49
    {
50 1
        $params = $this->parseQueryParams($request);
51
52 1
        if (isset($params['project']) && isset($params['article'])) {
53
            return $this->redirectToRoute('ArticleInfoResult', $params);
54
        }
55
56
        // Convert the given project (or default project) into a Project instance.
57 1
        $params['project'] = $this->getProjectFromQuery($params);
58
59 1
        return $this->render('articleInfo/index.html.twig', [
60 1
            'xtPage' => 'articleinfo',
61 1
            'xtPageTitle' => 'tool-articleinfo',
62 1
            'xtSubtitle' => 'tool-articleinfo-desc',
63 1
            'project' => $params['project'],
64
        ]);
65
    }
66
67
    /**
68
     * Generate ArticleInfo gadget script for use on-wiki. This automatically points the
69
     * script to this installation's API. Pass ?uglify=1 to uglify the code.
70
     *
71
     * @Route("/articleinfo-gadget.js", name="ArticleInfoGadget")
72
     * @link https://www.mediawiki.org/wiki/XTools#ArticleInfo_gadget
73
     *
74
     * @param Request $request The HTTP request
75
     * @return Response
76
     * @codeCoverageIgnore
77
     */
78
    public function gadgetAction(Request $request)
79
    {
80
        $rendered = $this->renderView('articleInfo/articleinfo.js.twig');
81
82
        // SUPER hacky, but it works and is safe.
83
        if ($request->query->get('uglify') != '') {
84
            // $ and " need to be escaped.
85
            $rendered = str_replace('$', '\$', trim($rendered));
86
            $rendered = str_replace('"', '\"', trim($rendered));
87
88
            // Uglify temporary file.
89
            $tmpFile = sys_get_temp_dir() . '/xtools_articleinfo_gadget.js';
90
            $script = "echo \"$rendered\" | tee $tmpFile >/dev/null && ";
91
            $script .= $this->get('kernel')->getRootDir() .
92
                "/Resources/node_modules/uglify-es/bin/uglifyjs $tmpFile --mangle " .
93
                "&& rm $tmpFile >/dev/null";
94
            $process = new Process($script);
95
            $process->run();
96
97
            // Check for errors.
98
            $errorOutput = $process->getErrorOutput();
99
            if ($errorOutput != '') {
100
                $response = new \Symfony\Component\HttpFoundation\Response(
101
                    "Error generating uglified JS. The server said:\n\n$errorOutput"
102
                );
103
                return $response;
104
            }
105
106
            // Remove escaping.
107
            $rendered = str_replace('\$', '$', trim($process->getOutput()));
108
            $rendered = str_replace('\"', '"', trim($rendered));
109
110
            // Add comment after uglifying since it removes comments.
111
            $rendered = "/**\n * This code was automatically generated and should not " .
112
                "be manually edited.\n * For updates, please copy and paste from " .
113
                $this->generateUrl('ArticleInfoGadget', ['uglify' => 1], UrlGeneratorInterface::ABSOLUTE_URL) .
114
                "\n * Released under GPL v3 license.\n */\n" . $rendered;
115
        }
116
117
        $response = new \Symfony\Component\HttpFoundation\Response($rendered);
118
        $response->headers->set('Content-Type', 'text/javascript');
119
        return $response;
120
    }
121
122
    /**
123
     * Display the results in given date range.
124
     * @Route(
125
     *    "/articleinfo/{project}/{article}/{start}/{end}", name="ArticleInfoResult",
126
     *     requirements={
127
     *         "article"=".+",
128
     *         "start"="|\d{4}-\d{2}-\d{2}",
129
     *         "end"="|\d{4}-\d{2}-\d{2}",
130
     *     }
131
     * )
132
     * @param Request $request
133
     * @param $article
134
     * @param null|string $start
135
     * @param null|string $end
136
     * @return Response
137
     * @codeCoverageIgnore
138
     */
139
    public function resultAction(Request $request, $article, $start = null, $end = null)
140
    {
141
        // This is some complicated stuff here. We pass $start and $end to method signature
142
        // for router regex parser to parse `article` with those parameters and then
143
        // manually retrieve what we want. It's done this way because programmatical way
144
        // is much easier (or maybe even only existing) solution for that.
145
146
        // Does path have `start` and `end` parameters (even empty ones)?
147
        if (1 === preg_match('/(.+?)\/(|\d{4}-\d{2}-\d{2})\/(|\d{4}-\d{2}-\d{2})$/', $article, $matches)) {
148
            $article = $matches[1];
149
            $start = $matches[2];
150
            $end = $matches[3];
151
        }
152
153
        list($start, $end) = $this->getUTCFromDateParams($start, $end, false);
154
155
        // In this case only the project is validated.
156
        $ret = $this->validateProjectAndUser($request);
157
        if ($ret instanceof RedirectResponse) {
158
            return $ret;
159
        } else {
160
            $project = $ret[0];
161
        }
162
163
        $page = $this->getAndValidatePage($project, $article);
164
        if ($page instanceof RedirectResponse) {
165
            return $page;
166
        }
167
168
        if (!$this->isDateRangeValid($page, $start, $end)) {
0 ignored issues
show
Bug introduced by
It seems like $start can also be of type string; however, parameter $start of AppBundle\Controller\Art...ler::isDateRangeValid() does only seem to accept integer|false, 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

168
        if (!$this->isDateRangeValid($page, /** @scrutinizer ignore-type */ $start, $end)) {
Loading history...
Bug introduced by
It seems like $end can also be of type string; however, parameter $end of AppBundle\Controller\Art...ler::isDateRangeValid() does only seem to accept integer|false, 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

168
        if (!$this->isDateRangeValid($page, $start, /** @scrutinizer ignore-type */ $end)) {
Loading history...
169
            $this->addFlash('notice', ['date-range-outside-revisions']);
0 ignored issues
show
Bug introduced by
array('date-range-outside-revisions') of type array<integer,string> is incompatible with the type string expected by parameter $message of Symfony\Bundle\Framework...\Controller::addFlash(). ( Ignorable by Annotation )

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

169
            $this->addFlash('notice', /** @scrutinizer ignore-type */ ['date-range-outside-revisions']);
Loading history...
170
171
            return $this->redirectToRoute('ArticleInfoResult', [
172
                'project' => $request->get('project'),
173
                'article' => $article
174
            ]);
175
        }
176
177
        $articleInfoRepo = new ArticleInfoRepository();
178
        $articleInfoRepo->setContainer($this->container);
179
        $articleInfo = new ArticleInfo($page, $this->container, $start, $end);
0 ignored issues
show
Bug introduced by
It seems like $end can also be of type string; however, parameter $end of Xtools\ArticleInfo::__construct() does only seem to accept integer|false, 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

179
        $articleInfo = new ArticleInfo($page, $this->container, $start, /** @scrutinizer ignore-type */ $end);
Loading history...
Bug introduced by
It seems like $start can also be of type string; however, parameter $start of Xtools\ArticleInfo::__construct() does only seem to accept integer|false, 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

179
        $articleInfo = new ArticleInfo($page, $this->container, /** @scrutinizer ignore-type */ $start, $end);
Loading history...
180
        $articleInfo->setRepository($articleInfoRepo);
181
182
        $articleInfo->prepareData();
183
184
        $maxRevisions = $this->container->getParameter('app.max_page_revisions');
185
186
        // Show message if we hit the max revisions.
187
        if ($articleInfo->tooManyRevisions()) {
188
            // FIXME: i18n number_format?
189
            $this->addFlash('notice', ['too-many-revisions', number_format($maxRevisions), $maxRevisions]);
0 ignored issues
show
Bug introduced by
array('too-many-revision...isions), $maxRevisions) of type array<integer,string|mixed> is incompatible with the type string expected by parameter $message of Symfony\Bundle\Framework...\Controller::addFlash(). ( Ignorable by Annotation )

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

189
            $this->addFlash('notice', /** @scrutinizer ignore-type */ ['too-many-revisions', number_format($maxRevisions), $maxRevisions]);
Loading history...
190
        }
191
192
        $ret = [
193
            'xtPage' => 'articleinfo',
194
            'xtTitle' => $page->getTitle(),
195
            'project' => $project,
196
            'editorlimit' => $request->query->get('editorlimit', 20),
197
            'botlimit' => $request->query->get('botlimit', 10),
198
            'pageviewsOffset' => 60,
199
            'ai' => $articleInfo,
200
            'page' => $page,
201
        ];
202
203
        // Output the relevant format template.
204
        $format = $request->query->get('format', 'html');
205
        if ($format == '') {
206
            // The default above doesn't work when the 'format' parameter is blank.
207
            $format = 'html';
208
        }
209
        $response = $this->render("articleInfo/result.$format.twig", $ret);
210
        if ($format == 'wikitext') {
211
            $response->headers->set('Content-Type', 'text/plain');
212
        }
213
214
        return $response;
215
    }
216
217
    /**
218
     * Check if there were any revisions of given page in given date range.
219
     * @param Page $page
220
     * @param false|int $start
221
     * @param false|int $end
222
     * @return bool
223
     */
224
    private function isDateRangeValid(Page $page, $start, $end)
225
    {
226
        return $page->getNumRevisions(null, $start, $end) > 0;
227
    }
228
229
    /************************ API endpoints ************************/
230
231
    /**
232
     * Get basic info on a given article.
233
     * @Route("/api/articleinfo/{project}/{article}", requirements={"article"=".+"})
234
     * @Route("/api/page/articleinfo/{project}/{article}", requirements={"article"=".+"})
235
     * @param Request $request The HTTP request.
236
     * @param string $project
237
     * @param string $article
238
     * @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...
239
     * See ArticleInfoControllerTest::testArticleInfoApi()
240
     * @codeCoverageIgnore
241
     */
242
    public function articleInfoApiAction(Request $request, $project, $article)
243
    {
244
        /** @var integer Number of days to query for pageviews */
245
        $pageviewsOffset = 30;
246
247
        $projectData = ProjectRepository::getProject($project, $this->container);
248
        if (!$projectData->exists()) {
249
            return new JsonResponse(
250
                ['error' => "$project is not a valid project"],
251
                Response::HTTP_NOT_FOUND
252
            );
253
        }
254
255
        $page = $this->getAndValidatePage($projectData, $article);
256
        if ($page instanceof RedirectResponse) {
257
            return new JsonResponse(
258
                ['error' => "$article was not found"],
259
                Response::HTTP_NOT_FOUND
260
            );
261
        }
262
263
        $data = [
264
            'project' => $projectData->getDomain(),
265
            'page' => $page->getTitle(),
266
            'watchers' => (int) $page->getWatchers(),
267
            'pageviews' => $page->getLastPageviews($pageviewsOffset),
268
            'pageviews_offset' => $pageviewsOffset,
269
        ];
270
271
        try {
272
            $info = $page->getBasicEditingInfo();
273
        } catch (\Doctrine\DBAL\Exception\DriverException $e) {
274
            /**
275
             * The query most likely exceeded the maximum query time,
276
             * so we'll abort and give only info retrived by the API.
277
             */
278
            $data['error'] = 'Unable to fetch revision data. The query may have timed out.';
279
        }
280
281
        if (isset($info)) {
282
            $creationDateTime = DateTime::createFromFormat('YmdHis', $info['created_at']);
283
            $modifiedDateTime = DateTime::createFromFormat('YmdHis', $info['modified_at']);
284
            $secsSinceLastEdit = (new DateTime)->getTimestamp() - $modifiedDateTime->getTimestamp();
285
286
            $data = array_merge($data, [
287
                'revisions' => (int) $info['num_edits'],
288
                'editors' => (int) $info['num_editors'],
289
                'author' => $info['author'],
290
                'author_editcount' => (int) $info['author_editcount'],
291
                'created_at' => $creationDateTime->format('Y-m-d'),
292
                'created_rev_id' => $info['created_rev_id'],
293
                'modified_at' => $modifiedDateTime->format('Y-m-d H:i'),
294
                'secs_since_last_edit' => $secsSinceLastEdit,
295
                'last_edit_id' => (int) $info['modified_rev_id'],
296
            ]);
297
        }
298
299
        if ($request->query->get('format') === 'html') {
300
            return $this->getApiHtmlResponse($projectData, $page, $data);
301
        }
302
303
        $body = array_merge([
304
            'project' => $projectData->getDomain(),
305
            'page' => $page->getTitle(),
306
        ], $data);
307
308
        return new JsonResponse(
309
            $body,
310
            Response::HTTP_OK
311
        );
312
    }
313
314
    /**
315
     * Get the Response for the HTML output of the ArticleInfo API action.
316
     * @param  Project  $project
317
     * @param  Page     $page
318
     * @param  string[] $data The pre-fetched data.
319
     * @return Response
320
     */
321
    private function getApiHtmlResponse(Project $project, Page $page, $data)
322
    {
323
        $response = $this->render('articleInfo/api.html.twig', [
324
            'data' => $data,
325
            'project' => $project,
326
            'page' => $page,
327
        ]);
328
329
        // All /api routes by default respond with a JSON content type.
330
        $response->headers->set('Content-Type', 'text/html');
331
332
        // This endpoint is hit constantly and user could be browsing the same page over
333
        // and over (popular noticeboard, for instance), so offload brief caching to browser.
334
        $response->setClientTtl(350);
335
336
        return $response;
337
    }
338
}
339