Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 108
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
CompareHandler
0.00% covered (danger)
0.00%
0 / 108
0.00% covered (danger)
0.00%
0 / 13
650
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
12
 getRevision
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getRevisionOrThrow
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 isAccessible
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRole
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRevisionText
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
30
 getJsonDiff
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getSectionInfo
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 needsWriteAccess
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getResponseBodySchemaFileName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getParamSettings
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 getResponseHeaderSettings
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Rest\Handler;
4
5use MediaWiki\Content\TextContent;
6use MediaWiki\Parser\ParserFactory;
7use MediaWiki\Rest\Handler;
8use MediaWiki\Rest\LocalizedHttpException;
9use MediaWiki\Rest\ResponseHeaders;
10use MediaWiki\Rest\StringStream;
11use MediaWiki\Revision\RevisionAccessException;
12use MediaWiki\Revision\RevisionLookup;
13use MediaWiki\Revision\RevisionRecord;
14use MediaWiki\Revision\SlotRecord;
15use MediaWiki\Revision\SuppressedDataException;
16use Wikimedia\Message\MessageValue;
17use Wikimedia\ParamValidator\ParamValidator;
18
19class CompareHandler extends Handler {
20    private RevisionLookup $revisionLookup;
21    private ParserFactory $parserFactory;
22
23    /** @var RevisionRecord[] */
24    private $revisions = [];
25
26    /** @var string[] */
27    private $textCache = [];
28
29    public function __construct(
30        RevisionLookup $revisionLookup,
31        ParserFactory $parserFactory
32    ) {
33        $this->revisionLookup = $revisionLookup;
34        $this->parserFactory = $parserFactory;
35    }
36
37    /** @inheritDoc */
38    public function execute() {
39        $fromRev = $this->getRevisionOrThrow( 'from' );
40        $toRev = $this->getRevisionOrThrow( 'to' );
41
42        if ( $fromRev->getPageId() !== $toRev->getPageId() ) {
43            throw new LocalizedHttpException(
44                new MessageValue( 'rest-compare-page-mismatch' ), 400 );
45        }
46
47        if ( !$this->getAuthority()->authorizeRead( 'read', $toRev->getPage() ) ) {
48            throw new LocalizedHttpException(
49                new MessageValue( 'rest-compare-permission-denied' ), 403 );
50        }
51
52        $data = [
53            'from' => [
54                'id' => $fromRev->getId(),
55                'slot_role' => $this->getRole(),
56                'sections' => $this->getSectionInfo( 'from' )
57            ],
58            'to' => [
59                'id' => $toRev->getId(),
60                'slot_role' => $this->getRole(),
61                'sections' => $this->getSectionInfo( 'to' )
62            ],
63            'diff' => [ 'PLACEHOLDER' => null ]
64        ];
65        $rf = $this->getResponseFactory();
66        $wrapperJson = $rf->encodeJson( $data );
67        $diff = $this->getJsonDiff();
68        $response = $rf->create();
69        $response->setHeader( 'Content-Type', 'application/json' );
70        // A hack until getJsonDiff() is moved to SlotDiffRenderer and only nested inner diff is returned
71        $innerDiff = substr( $diff, 1, -1 );
72        $response->setBody( new StringStream(
73            str_replace( '"diff":{"PLACEHOLDER":null}', $innerDiff, $wrapperJson ) ) );
74        return $response;
75    }
76
77    /**
78     * @param string $paramName
79     * @return RevisionRecord|null
80     */
81    private function getRevision( $paramName ) {
82        if ( !isset( $this->revisions[$paramName] ) ) {
83            $this->revisions[$paramName] =
84                $this->revisionLookup->getRevisionById( $this->getValidatedParams()[$paramName] );
85        }
86        return $this->revisions[$paramName];
87    }
88
89    /**
90     * @param string $paramName
91     * @return RevisionRecord
92     * @throws LocalizedHttpException
93     */
94    private function getRevisionOrThrow( $paramName ) {
95        $rev = $this->getRevision( $paramName );
96        if ( !$rev ) {
97            throw new LocalizedHttpException(
98                new MessageValue( 'rest-compare-nonexistent', [ $paramName ] ), 404 );
99        }
100
101        if ( !$this->isAccessible( $rev ) ) {
102            throw new LocalizedHttpException(
103                new MessageValue( 'rest-compare-inaccessible', [ $paramName ] ), 403 );
104        }
105        return $rev;
106    }
107
108    /**
109     * @param RevisionRecord $rev
110     * @return bool
111     */
112    private function isAccessible( $rev ) {
113        return $rev->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() );
114    }
115
116    private function getRole(): string {
117        return SlotRecord::MAIN;
118    }
119
120    private function getRevisionText( string $paramName ): string {
121        if ( !isset( $this->textCache[$paramName] ) ) {
122            $revision = $this->getRevision( $paramName );
123            try {
124                $content = $revision
125                    ->getSlot( $this->getRole(), RevisionRecord::FOR_THIS_USER, $this->getAuthority() )
126                    ->getContent()
127                    ->convert( CONTENT_MODEL_TEXT );
128                if ( $content instanceof TextContent ) {
129                    $this->textCache[$paramName] = $content->getText();
130                } else {
131                    throw new LocalizedHttpException(
132                        new MessageValue(
133                            'rest-compare-wrong-content',
134                            [ $this->getRole(), $paramName ]
135                        ),
136                        400 );
137                }
138            } catch ( SuppressedDataException ) {
139                throw new LocalizedHttpException(
140                    new MessageValue( 'rest-compare-inaccessible', [ $paramName ] ), 403 );
141            } catch ( RevisionAccessException ) {
142                throw new LocalizedHttpException(
143                    new MessageValue( 'rest-compare-nonexistent', [ $paramName ] ), 404 );
144            }
145        }
146        return $this->textCache[$paramName];
147    }
148
149    /**
150     * @return string
151     */
152    private function getJsonDiff() {
153        // TODO: properly implement
154        // This is a prototype only. SlotDiffRenderer should be extended to support this use case.
155        $fromText = $this->getRevisionText( 'from' );
156        $toText = $this->getRevisionText( 'to' );
157        if ( !function_exists( 'wikidiff2_inline_json_diff' ) ) {
158            throw new LocalizedHttpException(
159                new MessageValue( 'rest-compare-wikidiff2' ), 500 );
160        }
161        return wikidiff2_inline_json_diff( $fromText, $toText, 2 );
162    }
163
164    /**
165     * @param string $paramName
166     * @return array
167     */
168    private function getSectionInfo( $paramName ) {
169        $text = $this->getRevisionText( $paramName );
170        $parserSections = $this->parserFactory->getInstance()->getFlatSectionInfo( $text );
171        $sections = [];
172        foreach ( $parserSections as $i => $parserSection ) {
173            // Skip section zero, which comes before the first heading, since
174            // its offset is always zero, so the client can assume its location.
175            if ( $i !== 0 ) {
176                $sections[] = [
177                    'level' => $parserSection['level'],
178                    'heading' => $parserSection['heading'],
179                    'offset' => $parserSection['offset'],
180                ];
181            }
182        }
183        return $sections;
184    }
185
186    /**
187     * @inheritDoc
188     */
189    public function needsWriteAccess() {
190        return false;
191    }
192
193    protected function getResponseBodySchemaFileName( string $method ): ?string {
194        return __DIR__ . '/Schema/RevisionCompare.json';
195    }
196
197    /** @inheritDoc */
198    public function getParamSettings() {
199        return [
200            'from' => [
201                ParamValidator::PARAM_TYPE => 'integer',
202                ParamValidator::PARAM_REQUIRED => true,
203                Handler::PARAM_SOURCE => 'path',
204                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-compare-from' ),
205            ],
206            'to' => [
207                ParamValidator::PARAM_TYPE => 'integer',
208                ParamValidator::PARAM_REQUIRED => true,
209                Handler::PARAM_SOURCE => 'path',
210                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-compare-to' ),
211            ],
212        ];
213    }
214
215    /** @inheritDoc */
216    public function getResponseHeaderSettings(): array {
217        return array_merge(
218            parent::getResponseHeaderSettings(),
219            [
220                ResponseHeaders::CONTENT_TYPE => ResponseHeaders::RESPONSE_HEADER_DEFINITIONS[
221                    ResponseHeaders::CONTENT_TYPE
222                ]
223            ]
224        );
225    }
226}