Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
86.67% |
104 / 120 |
|
36.36% |
4 / 11 |
CRAP | |
0.00% |
0 / 1 |
ReadingListsHandlerTrait | |
86.67% |
104 / 120 |
|
36.36% |
4 / 11 |
36.74 | |
0.00% |
0 / 1 |
createRepository | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
getRepository | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getListFromRow | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
3.00 | |||
getListEntryFromRow | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
3.00 | |||
makeNext | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
decodeNext | |
75.00% |
12 / 16 |
|
0.00% |
0 / 1 |
3.14 | |||
getBatchOps | |
78.95% |
15 / 19 |
|
0.00% |
0 / 1 |
12.13 | |||
requireAtLeastOneBatchParameter | |
63.64% |
7 / 11 |
|
0.00% |
0 / 1 |
3.43 | |||
getSortParamSettings | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
1 | |||
checkAuthority | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
createListEntry | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
3.01 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\ReadingLists\Rest; |
4 | |
5 | use MediaWiki\Config\Config; |
6 | use MediaWiki\Context\RequestContext; |
7 | use MediaWiki\Extension\ReadingLists\Doc\ReadingListEntryRow; |
8 | use MediaWiki\Extension\ReadingLists\Doc\ReadingListEntryRowWithMergeFlag; |
9 | use MediaWiki\Extension\ReadingLists\Doc\ReadingListRow; |
10 | use MediaWiki\Extension\ReadingLists\Doc\ReadingListRowWithMergeFlag; |
11 | use MediaWiki\Extension\ReadingLists\ReadingListRepository; |
12 | use MediaWiki\Extension\ReadingLists\ReadingListRepositoryException; |
13 | use MediaWiki\Permissions\Authority; |
14 | use MediaWiki\Rest\Validator\Validator; |
15 | use MediaWiki\Title\Title; |
16 | use MediaWiki\User\CentralId\CentralIdLookup; |
17 | use MediaWiki\User\UserIdentity; |
18 | use Psr\Log\LoggerInterface; |
19 | use Wikimedia\Message\MessageValue; |
20 | use Wikimedia\ParamValidator\ParamValidator; |
21 | use Wikimedia\ParamValidator\TypeDef\NumericDef; |
22 | use Wikimedia\Rdbms\LBFactory; |
23 | |
24 | /** |
25 | * Trait to make the ReadingListRepository data access object available to |
26 | * ReadingLists REST handlers, and other helper code. |
27 | */ |
28 | trait ReadingListsHandlerTrait { |
29 | use RestUtilTrait; |
30 | |
31 | private ?ReadingListRepository $repository = null; |
32 | |
33 | private static int $MAX_BATCH_SIZE = 500; |
34 | |
35 | /** |
36 | * Map of sort/dir keywords used by the API to sort/dir keywords used by the repo. |
37 | * @var string[] |
38 | */ |
39 | private static array $sortParamMap = [ |
40 | 'name' => ReadingListRepository::SORT_BY_NAME, |
41 | 'updated' => ReadingListRepository::SORT_BY_UPDATED, |
42 | 'ascending' => ReadingListRepository::SORT_DIR_ASC, |
43 | 'descending' => ReadingListRepository::SORT_DIR_DESC, |
44 | ]; |
45 | |
46 | /** |
47 | * @param UserIdentity $user |
48 | * @param LBFactory $dbProvider |
49 | * @param Config $config |
50 | * @param CentralIdLookup $centralIdLookup |
51 | * @param LoggerInterface $logger |
52 | * @return ReadingListRepository |
53 | */ |
54 | private function createRepository( |
55 | UserIdentity $user, LBFactory $dbProvider, Config $config, |
56 | CentralIdLookup $centralIdLookup, LoggerInterface $logger |
57 | ) { |
58 | $centralId = $centralIdLookup->centralIdFromLocalUser( $user, CentralIdLookup::AUDIENCE_RAW ); |
59 | $repository = new ReadingListRepository( $centralId, $dbProvider ); |
60 | $repository->setLimits( |
61 | $config->get( 'ReadingListsMaxListsPerUser' ), |
62 | $config->get( 'ReadingListsMaxEntriesPerList' ) |
63 | ); |
64 | $repository->setLogger( $logger ); |
65 | return $repository; |
66 | } |
67 | |
68 | /** |
69 | * @return ?ReadingListRepository |
70 | */ |
71 | protected function getRepository(): ?ReadingListRepository { |
72 | return $this->repository; |
73 | } |
74 | |
75 | /** |
76 | * Convert a list record from ReadingListRepository into an array suitable for adding to |
77 | * the API result. |
78 | * @param ReadingListRow|ReadingListRowWithMergeFlag $row |
79 | * @return array |
80 | */ |
81 | private function getListFromRow( $row ) { |
82 | // The "default" value was not included in the old RESTBase responses, but it was included |
83 | // in the old Action API responses. We get it for free, so we include it. |
84 | $item = [ |
85 | 'id' => (int)$row->rl_id, |
86 | 'name' => $row->rl_name, |
87 | 'default' => (bool)$row->rl_is_default, |
88 | 'description' => $row->rl_description, |
89 | 'created' => wfTimestamp( TS_ISO_8601, $row->rl_date_created ), |
90 | 'updated' => wfTimestamp( TS_ISO_8601, $row->rl_date_updated ), |
91 | ]; |
92 | // @phan-suppress-next-line MediaWikiNoIssetIfDefined |
93 | if ( isset( $row->merged ) ) { |
94 | $item['duplicate'] = (bool)$row->merged; |
95 | } |
96 | if ( $row->rl_deleted ) { |
97 | $item['deleted'] = (bool)$row->rl_deleted; |
98 | } |
99 | return $item; |
100 | } |
101 | |
102 | /** |
103 | * Convert a list entry record from ReadingListRepository into an array suitable for adding to |
104 | * the API result. |
105 | * @param ReadingListEntryRow|ReadingListEntryRowWithMergeFlag $row |
106 | * @return array |
107 | */ |
108 | private function getListEntryFromRow( $row ) { |
109 | $item = [ |
110 | 'id' => (int)$row->rle_id, |
111 | 'listId' => (int)$row->rle_rl_id, |
112 | 'project' => $row->rlp_project, |
113 | 'title' => $row->rle_title, |
114 | 'created' => wfTimestamp( TS_ISO_8601, $row->rle_date_created ), |
115 | 'updated' => wfTimestamp( TS_ISO_8601, $row->rle_date_updated ), |
116 | ]; |
117 | // @phan-suppress-next-line MediaWikiNoIssetIfDefined |
118 | if ( isset( $row->merged ) ) { |
119 | $item['duplicate'] = (bool)$row->merged; |
120 | } |
121 | if ( $row->rle_deleted ) { |
122 | $item['deleted'] = true; |
123 | } |
124 | return $item; |
125 | } |
126 | |
127 | /** |
128 | * Extract continuation data from item position and serialize it into a string. |
129 | * @param array $item Result item to continue from. |
130 | * @param string $sort One of the SORT_BY_* constants. |
131 | * @param string $name The item name |
132 | * @return string |
133 | */ |
134 | protected function makeNext( array $item, string $sort, string $name ): string { |
135 | if ( $sort === ReadingListRepository::SORT_BY_NAME ) { |
136 | $next = $name . '|' . $item['id']; |
137 | } else { |
138 | $next = $item['updated'] . '|' . $item['id']; |
139 | } |
140 | return $next; |
141 | } |
142 | |
143 | /** |
144 | * Recover continuation data after it has been roundtripped to the client. |
145 | * |
146 | * @param string|null $encodedNext Continuation parameter returned by the client. |
147 | * @param string $sort One of the SORT_BY_* constants. |
148 | * @return null|int|string[] |
149 | * Continuation token format is: |
150 | * - null if there was no continuation parameter, OR |
151 | * - id for lists/pages/{project}/{title} OR |
152 | * - [ name, id ] when sorting by name OR |
153 | * - [ date_updated, id ] when sorting by updated time |
154 | */ |
155 | protected function decodeNext( $encodedNext, $sort ) { |
156 | if ( !$encodedNext ) { |
157 | return null; |
158 | } |
159 | |
160 | // Continue token format is '<name|timestamp>|<id>'; name can contain '|'. |
161 | // We don't deal with the lists/pages/{project}/{title} case herein. This function is |
162 | // overridden in that handler. |
163 | $separatorPosition = strrpos( $encodedNext, '|' ); |
164 | $this->dieIf( $separatorPosition === false, 'apierror-badcontinue' ); |
165 | $continue = [ |
166 | substr( $encodedNext, 0, $separatorPosition ), |
167 | substr( $encodedNext, $separatorPosition + 1 ), |
168 | ]; |
169 | $this->dieIf( $continue[1] !== (string)(int)$continue[1], 'apierror-badcontinue' ); |
170 | $continue[1] = (int)$continue[1]; |
171 | if ( $sort === ReadingListRepository::SORT_BY_UPDATED ) { |
172 | $this->dieIf( |
173 | wfTimestamp( TS_MW, $continue[0] ) === false, |
174 | 'apierror-badcontinue' |
175 | ); |
176 | } |
177 | return $continue; |
178 | } |
179 | |
180 | /** |
181 | * Decode, validate and normalize the 'batch' parameter of write APIs. |
182 | * @param array $batch Decoded value of the 'batch' parameter. |
183 | * @return array[] One operation, typically a flat associative array. |
184 | */ |
185 | protected function getBatchOps( $batch ) { |
186 | // TODO: consider alternatives to referencing the global services instance. |
187 | // RequestInterface doesn't provide normalizeUnicode(), so we can't use the |
188 | // $this->getRequest() method on Handler. |
189 | $request = RequestContext::getMain()->getRequest(); |
190 | |
191 | // Must be a real array, and not empty. |
192 | if ( !is_array( $batch ) || $batch !== array_values( $batch ) || !$batch ) { |
193 | // TODO: reconsider this once we have the ability to do deeper json body validation. |
194 | // This may become redundant and unnecessary. |
195 | if ( json_last_error() ) { |
196 | $jsonError = json_last_error_msg(); |
197 | $this->die( 'apierror-readinglists-batch-invalid-json', [ wfEscapeWikiText( $jsonError ) ] ); |
198 | } |
199 | $this->die( 'apierror-readinglists-batch-invalid-structure' ); |
200 | } |
201 | |
202 | $i = 0; |
203 | foreach ( $batch as &$op ) { |
204 | if ( ++$i > self::$MAX_BATCH_SIZE ) { |
205 | $this->die( 'apierror-readinglists-batch-toomanyvalues', [ self::$MAX_BATCH_SIZE ] ); |
206 | } |
207 | // Each batch operation must be an associative array with scalar fields. |
208 | if ( |
209 | !is_array( $op ) |
210 | || array_values( $op ) === $op |
211 | || array_filter( $op, 'is_scalar' ) !== $op |
212 | ) { |
213 | $this->die( 'apierror-readinglists-batch-invalid-structure' ); |
214 | } |
215 | // JSON-escaped characters might have skipped WebRequest's normalization, repeat it. |
216 | array_walk_recursive( $op, static function ( &$value ) use ( $request ) { |
217 | if ( is_string( $value ) ) { |
218 | $value = $request->normalizeUnicode( $value ); |
219 | } |
220 | } ); |
221 | } |
222 | return $batch; |
223 | } |
224 | |
225 | /** |
226 | * Validate a single operation in the 'batch' parameter of write APIs. Works the same way as |
227 | * requireAtLeastOneParameter. |
228 | * @param array $op |
229 | * @param string ...$param |
230 | */ |
231 | protected function requireAtLeastOneBatchParameter( array $op, ...$param ) { |
232 | $intersection = array_intersect( |
233 | array_keys( array_filter( $op, static function ( $val ) { |
234 | return $val !== null && $val !== false; |
235 | } ) ), |
236 | $param |
237 | ); |
238 | |
239 | if ( count( $intersection ) == 0 ) { |
240 | $mv = MessageValue::new( 'apierror-readinglists-batch-missingparam-at-least-one-of' ) |
241 | ->textListParams( $param ) |
242 | ->numParams( count( $param ) ); |
243 | $this->die( $mv ); |
244 | } |
245 | } |
246 | |
247 | /** |
248 | * Get common sorting/paging related params for getParamSettings(). |
249 | * @return array[] |
250 | */ |
251 | public function getSortParamSettings( int $maxLimit, string $defaultSort ) { |
252 | return [ |
253 | 'sort' => [ |
254 | Validator::PARAM_SOURCE => 'query', |
255 | ParamValidator::PARAM_DEFAULT => $defaultSort, |
256 | ParamValidator::PARAM_TYPE => [ 'name', 'updated' ], |
257 | ParamValidator::PARAM_REQUIRED => false, |
258 | ], |
259 | 'dir' => [ |
260 | Validator::PARAM_SOURCE => 'query', |
261 | ParamValidator::PARAM_DEFAULT => 'ascending', |
262 | ParamValidator::PARAM_TYPE => [ 'ascending', 'descending' ], |
263 | ParamValidator::PARAM_REQUIRED => false, |
264 | ], |
265 | 'limit' => [ |
266 | Validator::PARAM_SOURCE => 'query', |
267 | ParamValidator::PARAM_TYPE => 'integer', |
268 | ParamValidator::PARAM_REQUIRED => false, |
269 | ParamValidator::PARAM_DEFAULT => 10, |
270 | NumericDef::PARAM_MIN => 1, |
271 | NumericDef::PARAM_MAX => $maxLimit, |
272 | ], |
273 | ]; |
274 | } |
275 | |
276 | /** |
277 | * @param Authority $authority |
278 | */ |
279 | private function checkAuthority( Authority $authority ) { |
280 | if ( !$authority->isNamed() ) { |
281 | $this->die( 'rest-permission-denied-anon' ); |
282 | } |
283 | |
284 | if ( !$authority->isAllowed( 'viewmyprivateinfo' ) ) { |
285 | $this->die( 'rest-permission-error', [ 'viewmyprivateinfo' ] ); |
286 | } |
287 | } |
288 | |
289 | /** |
290 | * @param int $id the list to update |
291 | * @param string $project |
292 | * @param string $title |
293 | * @param ?ReadingListRepository $repository |
294 | * @return array |
295 | */ |
296 | public function createListEntry( |
297 | int $id, string $project, string $title, ?ReadingListRepository $repository |
298 | ) { |
299 | // Lists can contain titles from other wikis, and we have no idea of the exact title |
300 | // validation rules used there; but in practice it's unlikely the rules would differ, |
301 | // and allowing things like <> or # in the title could result in vulnerabilities in |
302 | // clients that assume they are getting something sane. So let's validate anyway. |
303 | // We do not normalize, that would contain too much local logic (e.g. title case), and |
304 | // clients are expected to submit already normalized titles (that they got from the API) |
305 | // anyway. |
306 | if ( !Title::newFromText( $title ) ) { |
307 | $this->die( 'apierror-invalidtitle', [ wfEscapeWikiText( $title ) ] ); |
308 | } |
309 | |
310 | try { |
311 | $entry = $repository->addListEntry( $id, $project, $title ); |
312 | } catch ( ReadingListRepositoryException $e ) { |
313 | $this->die( $e->getMessageObject() ); |
314 | } |
315 | |
316 | return [ |
317 | 'id' => (int)$entry->rle_id, |
318 | 'entry' => $this->getListEntryFromRow( $entry ), |
319 | ]; |
320 | } |
321 | } |