MetaController::getIndexRoute()   A
last analyzed

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 DateTime;
8
use Doctrine\DBAL\Connection;
9
use Doctrine\Persistence\ManagerRegistry;
10
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
11
use Symfony\Component\HttpFoundation\JsonResponse;
12
use Symfony\Component\HttpFoundation\Request;
13
use Symfony\Component\HttpFoundation\Response;
14
use Symfony\Component\Routing\Annotation\Route;
15
16
/**
17
 * This controller serves everything for the Meta tool.
18
 */
19
class MetaController extends XtoolsController
20
{
21
    /**
22
     * @inheritDoc
23
     * @codeCoverageIgnore
24
     */
25
    public function getIndexRoute(): string
26
    {
27
        return 'Meta';
28
    }
29
30
    /**
31
     * Display the form.
32
     * @Route("/meta", name="meta")
33
     * @Route("/meta", name="Meta")
34
     * @Route("/meta/index.php", name="MetaIndexPhp")
35
     * @return Response
36
     */
37
    public function indexAction(): Response
38
    {
39
        if (isset($this->params['start']) && isset($this->params['end'])) {
40
            return $this->redirectToRoute('MetaResult', $this->params);
41
        }
42
43
        return $this->render('meta/index.html.twig', [
44
            'xtPage' => 'Meta',
45
            'xtPageTitle' => 'tool-meta',
46
            'xtSubtitle' => 'tool-meta-desc',
47
        ]);
48
    }
49
50
    /**
51
     * Display the results.
52
     * @Route(
53
     *     "/meta/{start}/{end}/{legacy}",
54
     *     name="MetaResult",
55
     *     requirements={
56
     *         "start"="\d{4}-\d{2}-\d{2}",
57
     *         "end"="\d{4}-\d{2}-\d{2}",
58
     *     },
59
     * )
60
     * @param ManagerRegistry $managerRegistry
61
     * @param bool $legacy Non-blank value indicates to show stats for legacy XTools
62
     * @return Response
63
     * @codeCoverageIgnore
64
     */
65
    public function resultAction(ManagerRegistry $managerRegistry, bool $legacy = false): Response
66
    {
67
        $db = $legacy ? 'toolsdb' : 'default';
68
        $table = $legacy ? 's51187__metadata.xtools_timeline' : 'usage_timeline';
69
        $client = $managerRegistry->getConnection($db);
70
71
        $toolUsage = $this->getToolUsageStats($client, $table);
72
        $apiUsage = $this->getApiUsageStats($client);
73
74
        return $this->render('meta/result.html.twig', [
75
            'xtPage' => 'Meta',
76
            'start' => $this->start,
77
            'end' => $this->end,
78
            'toolUsage' => $toolUsage,
79
            'apiUsage' => $apiUsage,
80
        ]);
81
    }
82
83
    /**
84
     * Get usage statistics of the core tools.
85
     * @param object $client
86
     * @param string $table Table to query.
87
     * @return array
88
     * @codeCoverageIgnore
89
     */
90
    private function getToolUsageStats(object $client, string $table): array
91
    {
92
        $start = date('Y-m-d', $this->start);
0 ignored issues
show
Bug introduced by
It seems like $this->start can also be of type boolean; however, parameter $timestamp of date() does only seem to accept integer|null, 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

92
        $start = date('Y-m-d', /** @scrutinizer ignore-type */ $this->start);
Loading history...
93
        $end = date('Y-m-d', $this->end);
94
        $data = $client->executeQuery("SELECT * FROM $table WHERE date >= :start AND date <= :end", [
95
            'start' => $start,
96
            'end' => $end,
97
        ])->fetchAllAssociative();
98
99
        // Create array of totals, along with formatted timeline data as needed by Chart.js
100
        $totals = [];
101
        $dateLabels = [];
102
        $timeline = [];
103
        $startObj = new DateTime($start);
104
        $endObj = new DateTime($end);
105
        $numDays = (int) $endObj->diff($startObj)->format("%a");
106
        $grandSum = 0;
107
108
        // Generate array of date labels
109
        for ($dateObj = new DateTime($start); $dateObj <= $endObj; $dateObj->modify('+1 day')) {
110
            $dateLabels[] = $dateObj->format('Y-m-d');
111
        }
112
113
        foreach ($data as $entry) {
114
            if (!isset($totals[$entry['tool']])) {
115
                $totals[$entry['tool']] = (int) $entry['count'];
116
117
                // Create arrays for each tool, filled with zeros for each date in the timeline
118
                $timeline[$entry['tool']] = array_fill(0, $numDays, 0);
119
            } else {
120
                $totals[$entry['tool']] += (int) $entry['count'];
121
            }
122
123
            $date = new DateTime($entry['date']);
124
            $dateIndex = (int) $date->diff($startObj)->format("%a");
125
            $timeline[$entry['tool']][$dateIndex] = (int) $entry['count'];
126
127
            $grandSum += $entry['count'];
128
        }
129
        arsort($totals);
130
131
        return [
132
            'totals' => $totals,
133
            'grandSum' => $grandSum,
134
            'dateLabels' => $dateLabels,
135
            'timeline' => $timeline,
136
        ];
137
    }
138
139
    /**
140
     * Get usage statistics of the API.
141
     * @param object $client
142
     * @return array
143
     * @codeCoverageIgnore
144
     */
145
    private function getApiUsageStats(object $client): array
146
    {
147
        $start = date('Y-m-d', $this->start);
0 ignored issues
show
Bug introduced by
It seems like $this->start can also be of type boolean; however, parameter $timestamp of date() does only seem to accept integer|null, 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

147
        $start = date('Y-m-d', /** @scrutinizer ignore-type */ $this->start);
Loading history...
148
        $end = date('Y-m-d', $this->end);
149
        $data = $client->executeQuery("SELECT * FROM usage_api_timeline WHERE date >= :start AND date <= :end", [
150
            'start' => $start,
151
            'end' => $end,
152
        ])->fetchAllAssociative();
153
154
        // Create array of totals, along with formatted timeline data as needed by Chart.js
155
        $totals = [];
156
        $dateLabels = [];
157
        $timeline = [];
158
        $startObj = new DateTime($start);
159
        $endObj = new DateTime($end);
160
        $numDays = (int) $endObj->diff($startObj)->format("%a");
161
        $grandSum = 0;
162
163
        // Generate array of date labels
164
        for ($dateObj = new DateTime($start); $dateObj <= $endObj; $dateObj->modify('+1 day')) {
165
            $dateLabels[] = $dateObj->format('Y-m-d');
166
        }
167
168
        foreach ($data as $entry) {
169
            if (!isset($totals[$entry['endpoint']])) {
170
                $totals[$entry['endpoint']] = (int) $entry['count'];
171
172
                // Create arrays for each endpoint, filled with zeros for each date in the timeline
173
                $timeline[$entry['endpoint']] = array_fill(0, $numDays, 0);
174
            } else {
175
                $totals[$entry['endpoint']] += (int) $entry['count'];
176
            }
177
178
            $date = new DateTime($entry['date']);
179
            $dateIndex = (int) $date->diff($startObj)->format("%a");
180
            $timeline[$entry['endpoint']][$dateIndex] = (int) $entry['count'];
181
182
            $grandSum += $entry['count'];
183
        }
184
        arsort($totals);
185
186
        return [
187
            'totals' => $totals,
188
            'grandSum' => $grandSum,
189
            'dateLabels' => $dateLabels,
190
            'timeline' => $timeline,
191
        ];
192
    }
193
194
    /**
195
     * Record usage of a particular XTools tool. This is called automatically
196
     *   in base.html.twig via JavaScript so that it is done asynchronously.
197
     * @Route("/meta/usage/{tool}/{project}/{token}")
198
     * @param Request $request
199
     * @param ParameterBagInterface $parameterBag
200
     * @param ManagerRegistry $managerRegistry
201
     * @param bool $singleWiki
202
     * @param string $tool Internal name of tool.
203
     * @param string $project Project domain such as en.wikipedia.org
204
     * @param string $token Unique token for this request, so we don't have people meddling with these statistics.
205
     * @return JsonResponse
206
     * @codeCoverageIgnore
207
     */
208
    public function recordUsageAction(
209
        Request $request,
210
        ParameterBagInterface $parameterBag,
211
        ManagerRegistry $managerRegistry,
212
        bool $singleWiki,
213
        string $tool,
214
        string $project,
215
        string $token
216
    ): Response {
217
        $response = new JsonResponse();
218
219
        // Validate method and token.
220
        if ('PUT' !== $request->getMethod() || !$this->isCsrfTokenValid('intention', $token)) {
221
            $response->setStatusCode(Response::HTTP_FORBIDDEN);
222
            $response->setContent(json_encode([
223
                'error' => 'This endpoint is for internal use only.',
224
            ]));
225
            return $response;
226
        }
227
228
        // Don't update counts for tools that aren't enabled
229
        $configKey = 'enable.'.ucfirst($tool);
230
        if (!$parameterBag->has($configKey) || !$parameterBag->get($configKey)) {
231
            $response->setStatusCode(Response::HTTP_FORBIDDEN);
232
            $response->setContent(json_encode([
233
                'error' => 'This tool is disabled',
234
            ]));
235
            return $response;
236
        }
237
238
        /** @var Connection $conn */
239
        $conn = $managerRegistry->getConnection('default');
240
        $date =  date('Y-m-d');
241
242
        // Tool name needs to be lowercase.
243
        $tool = strtolower($tool);
244
245
        $sql = "INSERT INTO usage_timeline
246
                VALUES(NULL, :date, :tool, 1)
247
                ON DUPLICATE KEY UPDATE `count` = `count` + 1";
248
        $conn->executeStatement($sql, [
0 ignored issues
show
Security SQL Injection introduced by
array('date' => $date, 'tool' => $tool) can contain request data and is used in sql context(s) leading to a potential security vulnerability.

1 path for user data to reach this point

  1. Enters via controller action parameter $tool
    in src/Controller/MetaController.php on line 213
  2. Data is passed through strtolower(), and strtolower($tool) is assigned to $tool
    in src/Controller/MetaController.php on line 243

Used in sql context

  1. Connection::executeStatement() is called
    in src/Controller/MetaController.php on line 243
  2. Enters via parameter $params
    in vendor/doctrine/dbal/lib/Doctrine/DBAL/Connection.php on line 1510
  3. Data is passed through expandListParameters()
    in vendor/doctrine/dbal/lib/Doctrine/DBAL/Connection.php on line 1521
  4. Doctrine\DBAL\SQLParserUtils::expandListParameters($sql, $params, $types) is assigned to $params
    in vendor/doctrine/dbal/lib/Doctrine/DBAL/Connection.php on line 1521
  5. DB2Statement::execute() is called
    in vendor/doctrine/dbal/lib/Doctrine/DBAL/Connection.php on line 1529
  6. Enters via parameter $params
    in vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/IBMDB2/DB2Statement.php on line 216
  7. db2_execute() is called
    in vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/IBMDB2/DB2Statement.php on line 238

Preventing SQL Injection

There are two options to prevent SQL injection. Generally, it is recommended to use parameter binding:

$stmt = mysqli_prepare("SELECT * FROM users WHERE name = ?");
$stmt->bind_param("s", $taintedUserName);

An alternative – although generally not recommended – is to escape your data manually:

$mysqli = new mysqli('localhost', 'user', 'pass', 'dbname');

$escaped = $mysqli->real_escape_string($taintedUserName);
$mysqli->query("SELECT * FROM users WHERE name = '".$escaped."'");

General Strategies to prevent injection

In general, it is advisable to prevent any user-data to reach this point. This can be done by white-listing certain values:

if ( ! in_array($value, array('this-is-allowed', 'and-this-too'), true)) {
    throw new \InvalidArgumentException('This input is not allowed.');
}

For numeric data, we recommend to explicitly cast the data:

$sanitized = (integer) $tainted;
Loading history...
249
            'date' => $date,
250
            'tool' => $tool,
251
        ]);
252
253
        // Update per-project usage, if applicable
254
        if (!$singleWiki) {
255
            $sql = "INSERT INTO usage_projects
256
                    VALUES(NULL, :tool, :project, 1)
257
                    ON DUPLICATE KEY UPDATE `count` = `count` + 1";
258
            $conn->executeStatement($sql, [
0 ignored issues
show
Security SQL Injection introduced by
array('tool' => $tool, 'project' => $project) can contain request data and is used in sql context(s) leading to a potential security vulnerability.

2 paths for user data to reach this point

  1. Path: Enters via controller action parameter $project in src/Controller/MetaController.php on line 214
  1. Enters via controller action parameter $project
    in src/Controller/MetaController.php on line 214
  2. Path: Enters via controller action parameter $tool in src/Controller/MetaController.php on line 213
  1. Enters via controller action parameter $tool
    in src/Controller/MetaController.php on line 213
  2. Data is passed through strtolower(), and strtolower($tool) is assigned to $tool
    in src/Controller/MetaController.php on line 243

Used in sql context

  1. Connection::executeStatement() is called
    in src/Controller/MetaController.php on line 253
  2. Enters via parameter $params
    in vendor/doctrine/dbal/lib/Doctrine/DBAL/Connection.php on line 1510
  3. Data is passed through expandListParameters()
    in vendor/doctrine/dbal/lib/Doctrine/DBAL/Connection.php on line 1521
  4. Doctrine\DBAL\SQLParserUtils::expandListParameters($sql, $params, $types) is assigned to $params
    in vendor/doctrine/dbal/lib/Doctrine/DBAL/Connection.php on line 1521
  5. DB2Statement::execute() is called
    in vendor/doctrine/dbal/lib/Doctrine/DBAL/Connection.php on line 1529
  6. Enters via parameter $params
    in vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/IBMDB2/DB2Statement.php on line 216
  7. db2_execute() is called
    in vendor/doctrine/dbal/lib/Doctrine/DBAL/Driver/IBMDB2/DB2Statement.php on line 238

Preventing SQL Injection

There are two options to prevent SQL injection. Generally, it is recommended to use parameter binding:

$stmt = mysqli_prepare("SELECT * FROM users WHERE name = ?");
$stmt->bind_param("s", $taintedUserName);

An alternative – although generally not recommended – is to escape your data manually:

$mysqli = new mysqli('localhost', 'user', 'pass', 'dbname');

$escaped = $mysqli->real_escape_string($taintedUserName);
$mysqli->query("SELECT * FROM users WHERE name = '".$escaped."'");

General Strategies to prevent injection

In general, it is advisable to prevent any user-data to reach this point. This can be done by white-listing certain values:

if ( ! in_array($value, array('this-is-allowed', 'and-this-too'), true)) {
    throw new \InvalidArgumentException('This input is not allowed.');
}

For numeric data, we recommend to explicitly cast the data:

$sanitized = (integer) $tainted;
Loading history...
259
                'tool' => $tool,
260
                'project' => $project,
261
            ]);
262
        }
263
264
        $response->setStatusCode(Response::HTTP_NO_CONTENT);
265
        $response->setContent(json_encode([]));
266
        return $response;
267
    }
268
}
269