Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.67% covered (warning)
86.67%
104 / 120
36.36% covered (danger)
36.36%
4 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
ReadingListsHandlerTrait
86.67% covered (warning)
86.67%
104 / 120
36.36% covered (danger)
36.36%
4 / 11
36.74
0.00% covered (danger)
0.00%
0 / 1
 createRepository
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getRepository
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getListFromRow
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
 getListEntryFromRow
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
 makeNext
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 decodeNext
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
3.14
 getBatchOps
78.95% covered (warning)
78.95%
15 / 19
0.00% covered (danger)
0.00%
0 / 1
12.13
 requireAtLeastOneBatchParameter
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
3.43
 getSortParamSettings
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 checkAuthority
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 createListEntry
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
1<?php
2
3namespace MediaWiki\Extension\ReadingLists\Rest;
4
5use MediaWiki\Config\Config;
6use MediaWiki\Extension\ReadingLists\Doc\ReadingListEntryRow;
7use MediaWiki\Extension\ReadingLists\Doc\ReadingListEntryRowWithMergeFlag;
8use MediaWiki\Extension\ReadingLists\Doc\ReadingListRow;
9use MediaWiki\Extension\ReadingLists\Doc\ReadingListRowWithMergeFlag;
10use MediaWiki\Extension\ReadingLists\ReadingListRepository;
11use MediaWiki\Extension\ReadingLists\ReadingListRepositoryException;
12use MediaWiki\Permissions\Authority;
13use MediaWiki\Rest\LocalizedHttpException;
14use MediaWiki\Rest\Validator\Validator;
15use MediaWiki\Title\Title;
16use MediaWiki\User\CentralId\CentralIdLookup;
17use MediaWiki\User\UserIdentity;
18use Psr\Log\LoggerInterface;
19use RequestContext;
20use Wikimedia\Message\MessageValue;
21use Wikimedia\ParamValidator\ParamValidator;
22use Wikimedia\ParamValidator\TypeDef\NumericDef;
23use 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 */
29trait 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}