Passed
Pull Request — main (#442)
by MusikAnimal
08:40 queued 04:21
created

CategoryEditsRepository   A

Complexity

Total Complexity 14

Size/Duplication

Total Lines 258
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 106
dl 0
loc 258
rs 10
c 0
b 0
f 0
wmc 14

6 Methods

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

Used in sql context

  1. Connection::executeQuery() is called
    in src/Repository/CategoryEditsRepository.php on line 264
  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...
265
    }
266
267
    /**
268
     * Get Edits given revision rows (JOINed on the page table).
269
     * @param Project $project
270
     * @param User $user
271
     * @param array $revs Each must contain 'page_title' and 'page_namespace'.
272
     * @return Edit[]
273
     */
274
    public function getEditsFromRevs(Project $project, User $user, array $revs): array
275
    {
276
        return Edit::getEditsFromRevs(
277
            $this->pageRepo,
278
            $this->editRepo,
279
            $this->userRepo,
280
            $project,
281
            $user,
282
            $revs
283
        );
284
    }
285
}
286