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