CategoryEditsRepository::executeStmt()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 31
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 19
nc 2
nop 4
dl 0
loc 31
rs 9.6333
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types = 1);
4
5
namespace App\Repository;
6
7
use App\Helper\AutomatedEditsHelper;
8
use App\Model\Edit;
9
use App\Model\Project;
10
use App\Model\User;
11
use Doctrine\DBAL\Connection;
12
use Doctrine\DBAL\Driver\ResultStatement;
13
use Doctrine\DBAL\ParameterType;
14
use Doctrine\Persistence\ManagerRegistry;
15
use GuzzleHttp\Client;
16
use Psr\Cache\CacheItemPoolInterface;
17
use Psr\Log\LoggerInterface;
18
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
19
use Wikimedia\IPUtils;
20
21
/**
22
 * CategoryEditsRepository is responsible for retrieving data from the database
23
 * about the edits made by a user to pages in a set of given categories.
24
 * @codeCoverageIgnore
25
 */
26
class CategoryEditsRepository extends Repository
27
{
28
    protected AutomatedEditsHelper $autoEditsHelper;
29
    protected EditRepository $editRepo;
30
    protected PageRepository $pageRepo;
31
    protected UserRepository $userRepo;
32
33
    /**
34
     * @param ManagerRegistry $managerRegistry
35
     * @param CacheItemPoolInterface $cache
36
     * @param Client $guzzle
37
     * @param LoggerInterface $logger
38
     * @param ParameterBagInterface $parameterBag
39
     * @param bool $isWMF
40
     * @param int $queryTimeout
41
     * @param AutomatedEditsHelper $autoEditsHelper
42
     * @param EditRepository $editRepo
43
     * @param PageRepository $pageRepo
44
     * @param UserRepository $userRepo
45
     */
46
    public function __construct(
47
        ManagerRegistry $managerRegistry,
48
        CacheItemPoolInterface $cache,
49
        Client $guzzle,
50
        LoggerInterface $logger,
51
        ParameterBagInterface $parameterBag,
52
        bool $isWMF,
53
        int $queryTimeout,
54
        AutomatedEditsHelper $autoEditsHelper,
55
        EditRepository $editRepo,
56
        PageRepository $pageRepo,
57
        UserRepository $userRepo
58
    ) {
59
        $this->autoEditsHelper = $autoEditsHelper;
60
        $this->editRepo = $editRepo;
61
        $this->pageRepo = $pageRepo;
62
        $this->userRepo = $userRepo;
63
        parent::__construct($managerRegistry, $cache, $guzzle, $logger, $parameterBag, $isWMF, $queryTimeout);
64
    }
65
66
    /**
67
     * Get the number of edits this user made to the given categories.
68
     * @param Project $project
69
     * @param User $user
70
     * @param string[] $categories
71
     * @param int|false $start Start date as Unix timestamp.
72
     * @param int|false $end End date as Unix timestamp.
73
     * @return int Result of query, see below.
74
     */
75
    public function countCategoryEdits(
76
        Project $project,
77
        User $user,
78
        array $categories,
79
        $start = false,
80
        $end = false
81
    ): int {
82
        $cacheKey = $this->getCacheKey(func_get_args(), 'user_categoryeditcount');
83
        if ($this->cache->hasItem($cacheKey)) {
84
            return $this->cache->getItem($cacheKey)->get();
85
        }
86
87
        $revisionTable = $project->getTableName('revision');
88
        $categorylinksTable = $project->getTableName('categorylinks');
89
        $revDateConditions = $this->getDateConditions($start, $end, false, 'revs.');
90
        $whereClause = 'revs.rev_actor = ?';
91
        $ipcJoin = '';
92
93
        if ($user->isIpRange()) {
94
            $ipcTable = $project->getTableName('ip_changes');
95
            $ipcJoin = "JOIN $ipcTable ON ipc_rev_id = revs.rev_id";
96
            $whereClause = 'ipc_hex BETWEEN ? AND ?';
97
        }
98
99
        $sql = "SELECT COUNT(DISTINCT revs.rev_id)
100
                FROM $revisionTable revs
101
                $ipcJoin
102
                JOIN $categorylinksTable ON cl_from = rev_page
103
                WHERE $whereClause
104
                    AND cl_to IN (?)
105
                    $revDateConditions";
106
        $result = (int)$this->executeStmt($sql, $project, $user, $categories)->fetchOne();
107
108
        // Cache and return.
109
        return $this->setCache($cacheKey, $result);
110
    }
111
112
    /**
113
     * Get number of edits within each individual category.
114
     * @param Project $project
115
     * @param User $user
116
     * @param array $categories
117
     * @param int|false $start
118
     * @param int|false $end
119
     * @return string[] With categories as keys, counts as values.
120
     */
121
    public function getCategoryCounts(
122
        Project $project,
123
        User $user,
124
        array $categories,
125
        $start = false,
126
        $end = false
127
    ): array {
128
        $cacheKey = $this->getCacheKey(func_get_args(), 'user_categorycounts');
129
        if ($this->cache->hasItem($cacheKey)) {
130
            return $this->cache->getItem($cacheKey)->get();
131
        }
132
133
        $revisionTable = $project->getTableName('revision');
134
        $categorylinksTable = $project->getTableName('categorylinks');
135
        $revDateConditions = $this->getDateConditions($start, $end, false, 'revs.');
136
        $whereClause = 'revs.rev_actor = ?';
137
        $ipcJoin = '';
138
139
        if ($user->isIpRange()) {
140
            $ipcTable = $project->getTableName('ip_changes');
141
            $ipcJoin = "JOIN $ipcTable ON ipc_rev_id = revs.rev_id";
142
            $whereClause = 'ipc_hex BETWEEN ? AND ?';
143
        }
144
145
        $sql = "SELECT cl_to AS cat, COUNT(rev_id) AS edit_count, COUNT(DISTINCT rev_page) AS page_count
146
                FROM $revisionTable revs
147
                $ipcJoin
148
                JOIN $categorylinksTable ON cl_from = rev_page
149
                WHERE $whereClause
150
                    AND cl_to IN (?)
151
                    $revDateConditions
152
                GROUP BY cl_to
153
                ORDER BY edit_count DESC";
154
155
        $counts = [];
156
        $stmt = $this->executeStmt($sql, $project, $user, $categories);
157
        while ($result = $stmt->fetchAssociative()) {
158
            $counts[$result['cat']] = [
159
                'editCount' => (int)$result['edit_count'],
160
                'pageCount' => (int)$result['page_count'],
161
            ];
162
        }
163
164
        // Cache and return.
165
        return $this->setCache($cacheKey, $counts);
166
    }
167
168
    /**
169
     * Get contributions made to the given categories.
170
     * @param Project $project
171
     * @param User $user
172
     * @param string[] $categories
173
     * @param int|false $start Start date as Unix timestamp.
174
     * @param int|false $end End date as Unix timestamp.
175
     * @param false|int $offset Unix timestamp. Used for pagination.
176
     * @return string[] Result of query, with columns 'page_title', 'namespace', 'rev_id', 'timestamp', 'minor',
177
     *   'length', 'length_change', 'comment'
178
     */
179
    public function getCategoryEdits(
180
        Project $project,
181
        User $user,
182
        array $categories,
183
        $start = false,
184
        $end = false,
185
        $offset = false
186
    ): array {
187
        $cacheKey = $this->getCacheKey(func_get_args(), 'user_categoryedits');
188
        if ($this->cache->hasItem($cacheKey)) {
189
            return $this->cache->getItem($cacheKey)->get();
190
        }
191
192
        $pageTable = $project->getTableName('page');
193
        $revisionTable = $project->getTableName('revision');
194
        $commentTable = $project->getTableName('comment');
195
        $categorylinksTable = $project->getTableName('categorylinks');
196
        $revDateConditions = $this->getDateConditions($start, $end, $offset, 'revs.');
197
        $whereClause = 'revs.rev_actor = ?';
198
        $ipcJoin = '';
199
200
        if ($user->isIpRange()) {
201
            $ipcTable = $project->getTableName('ip_changes');
202
            $ipcJoin = "JOIN $ipcTable ON ipc_rev_id = revs.rev_id";
203
            $whereClause = 'ipc_hex BETWEEN ? AND ?';
204
        }
205
206
        $sql = "SELECT page_title, page_namespace AS `namespace`, revs.rev_id AS `rev_id`,
207
                    revs.rev_timestamp AS `timestamp`, revs.rev_minor_edit AS `minor`, revs.rev_len AS `length`,
208
                    (CAST(revs.rev_len AS SIGNED) - IFNULL(parentrevs.rev_len, 0)) AS `length_change`,
209
                    comment_text AS `comment`
210
                FROM $pageTable
211
                JOIN $revisionTable revs ON page_id = revs.rev_page
212
                $ipcJoin
213
                JOIN $categorylinksTable ON cl_from = rev_page
214
                LEFT JOIN $commentTable comment ON revs.rev_comment_id = comment_id
215
                LEFT JOIN $revisionTable parentrevs ON revs.rev_parent_id = parentrevs.rev_id
216
                WHERE $whereClause
217
                    AND cl_to IN (?)
218
                    $revDateConditions
219
                GROUP BY revs.rev_id
220
                ORDER BY revs.rev_timestamp DESC
221
                LIMIT 50";
222
223
        $result = $this->executeStmt($sql, $project, $user, $categories)->fetchAllAssociative();
224
225
        // Cache and return.
226
        return $this->setCache($cacheKey, $result);
227
    }
228
229
    /**
230
     * Bind dates, username and categories then execute the query.
231
     * @param string $sql
232
     * @param Project $project
233
     * @param User $user
234
     * @param string[] $categories
235
     * @return ResultStatement
236
     */
237
    private function executeStmt(
238
        string $sql,
239
        Project $project,
240
        User $user,
241
        array $categories
242
    ): ResultStatement {
243
        if ($user->isIpRange()) {
244
            [$hexStart, $hexEnd] = IPUtils::parseRange($user->getUsername());
245
            $params = [
246
                $hexStart,
247
                $hexEnd,
248
                $categories,
249
            ];
250
            $types = [
251
                ParameterType::STRING,
252
                ParameterType::STRING,
253
                Connection::PARAM_STR_ARRAY,
254
            ];
255
        } else {
256
            $params = [
257
                $user->getActorId($project),
258
                $categories,
259
            ];
260
            $types = [
261
                ParameterType::STRING,
262
                Connection::PARAM_STR_ARRAY,
263
            ];
264
        }
265
266
        return $this->getProjectsConnection($project)
267
            ->executeQuery($sql, $params, $types);
0 ignored issues
show
Security SQL Injection introduced by
$params 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. ParameterBag::get() returns request data
    in vendor/symfony/http-foundation/ParameterBag.php on line 90
  2. $this->attributes->get($key, $this) is assigned to $result
    in vendor/symfony/http-foundation/Request.php on line 711
  3. $result is returned
    in vendor/symfony/http-foundation/Request.php on line 712
  4. Data is passed through explode(), and explode('|', $this->request->get('categories')) is assigned to $categories
    in src/Controller/CategoryEditsController.php on line 109
  5. Data is passed through array_map(), and array_map(function(...) { /* ... */ }, $categories) is assigned to property CategoryEditsController::$categories
    in src/Controller/CategoryEditsController.php on line 117
  6. Read from property CategoryEditsController::$categories, and CategoryEdits::__construct() is called
    in src/Controller/CategoryEditsController.php on line 86
  7. Enters via parameter $categories
    in src/Model/CategoryEdits.php on line 43
  8. Data is passed through array_map(), and array_map(function(...) { /* ... */ }, $categories) is assigned to property CategoryEdits::$categories
    in src/Model/CategoryEdits.php on line 51
  9. Read from property CategoryEdits::$categories, and CategoryEditsRepository::getCategoryCounts() is called
    in src/Model/CategoryEdits.php on line 198
  10. Enters via parameter $categories
    in src/Repository/CategoryEditsRepository.php on line 124
  11. CategoryEditsRepository::executeStmt() is called
    in src/Repository/CategoryEditsRepository.php on line 156
  12. Enters via parameter $categories
    in src/Repository/CategoryEditsRepository.php on line 241
  13. array($hexStart, $hexEnd, $categories) is assigned to $params
    in src/Repository/CategoryEditsRepository.php on line 245

Used in sql context

  1. Connection::executeQuery() is called
    in src/Repository/CategoryEditsRepository.php on line 267
  2. Enters via parameter $params
    in vendor/doctrine/dbal/lib/Doctrine/DBAL/Connection.php on line 1284
  3. Data is passed through expandListParameters()
    in vendor/doctrine/dbal/lib/Doctrine/DBAL/Connection.php on line 1299
  4. Doctrine\DBAL\SQLParserUtils::expandListParameters($sql, $params, $types) is assigned to $params
    in vendor/doctrine/dbal/lib/Doctrine/DBAL/Connection.php on line 1299
  5. DB2Statement::execute() is called
    in vendor/doctrine/dbal/lib/Doctrine/DBAL/Connection.php on line 1306
  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...
268
    }
269
270
    /**
271
     * Get Edits given revision rows (JOINed on the page table).
272
     * @param Project $project
273
     * @param User $user
274
     * @param array $revs Each must contain 'page_title' and 'namespace'.
275
     * @return Edit[]
276
     */
277
    public function getEditsFromRevs(Project $project, User $user, array $revs): array
278
    {
279
        return Edit::getEditsFromRevs(
280
            $this->pageRepo,
281
            $this->editRepo,
282
            $this->userRepo,
283
            $project,
284
            $user,
285
            $revs
286
        );
287
    }
288
}
289