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\Context\RequestContext;
7use MediaWiki\Extension\ReadingLists\Doc\ReadingListEntryRow;
8use MediaWiki\Extension\ReadingLists\Doc\ReadingListEntryRowWithMergeFlag;
9use MediaWiki\Extension\ReadingLists\Doc\ReadingListRow;
10use MediaWiki\Extension\ReadingLists\Doc\ReadingListRowWithMergeFlag;
11use MediaWiki\Extension\ReadingLists\ReadingListRepository;
12use MediaWiki\Extension\ReadingLists\ReadingListRepositoryException;
13use MediaWiki\Permissions\Authority;
14use MediaWiki\Rest\Validator\Validator;
15use MediaWiki\Title\Title;
16use MediaWiki\User\CentralId\CentralIdLookup;
17use MediaWiki\User\UserIdentity;
18use Psr\Log\LoggerInterface;
19use Wikimedia\Message\MessageValue;
20use Wikimedia\ParamValidator\ParamValidator;
21use Wikimedia\ParamValidator\TypeDef\NumericDef;
22use 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 */
28trait 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}