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