aboutsummaryrefslogtreecommitdiffstats
path: root/includes/Rest/Handler/UpdateHandler.php
blob: c6fc9b67fde469e9fcbc9ae32f2e641a66a8aaf6 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
<?php

namespace MediaWiki\Rest\Handler;

use MediaWiki\Api\IApiMessage;
use MediaWiki\Content\TextContent;
use MediaWiki\Json\FormatJson;
use MediaWiki\ParamValidator\TypeDef\ArrayDef;
use MediaWiki\Rest\Handler;
use MediaWiki\Rest\LocalizedHttpException;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Utils\MWTimestamp;
use Wikimedia\Message\MessageValue;
use Wikimedia\ParamValidator\ParamValidator;

/**
 * Core REST API endpoint that handles page updates (main slot only)
 */
class UpdateHandler extends EditHandler {

	/**
	 * @var callable
	 */
	private $jsonDiffFunction;

	/**
	 * @inheritDoc
	 */
	protected function getTitleParameter() {
		return $this->getValidatedParams()['title'];
	}

	/**
	 * Sets the function to use for JSON diffs, for testing.
	 */
	public function setJsonDiffFunction( callable $jsonDiffFunction ) {
		$this->jsonDiffFunction = $jsonDiffFunction;
	}

	/**
	 * @inheritDoc
	 */
	public function getParamSettings() {
		return [
			'title' => [
				self::PARAM_SOURCE => 'path',
				ParamValidator::PARAM_TYPE => 'string',
				ParamValidator::PARAM_REQUIRED => true,
				self::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-update-title' ),
			],
		] + parent::getParamSettings();
	}

	/**
	 * @inheritDoc
	 */
	public function getBodyParamSettings(): array {
		return [
			'source' => [
				self::PARAM_SOURCE => 'body',
				ParamValidator::PARAM_TYPE => 'string',
				ParamValidator::PARAM_REQUIRED => true,
				Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-source' )
			],
			'comment' => [
				self::PARAM_SOURCE => 'body',
				ParamValidator::PARAM_TYPE => 'string',
				ParamValidator::PARAM_REQUIRED => true,
				Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-comment' )
			],
			'content_model' => [
				self::PARAM_SOURCE => 'body',
				ParamValidator::PARAM_TYPE => 'string',
				ParamValidator::PARAM_REQUIRED => false,
				Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-contentmodel' )
			],
			'latest' => [
				self::PARAM_SOURCE => 'body',
				ParamValidator::PARAM_TYPE => 'array',
				ParamValidator::PARAM_REQUIRED => false,
				ArrayDef::PARAM_SCHEMA => ArrayDef::makeObjectSchema(
					[ 'id' => 'integer' ],
					[ 'timestamp' => 'string' ], // from GET response, will be ignored
				),
				Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-update-latest' )
			],
		] + $this->getTokenParamDefinition();
	}

	/**
	 * @inheritDoc
	 */
	protected function getActionModuleParameters() {
		$body = $this->getValidatedBody();
		'@phan-var array $body';

		$title = $this->getTitleParameter();
		$baseRevId = $body['latest']['id'] ?? 0;

		$contentmodel = $body['content_model'] ?: null;

		if ( $contentmodel !== null && !$this->contentHandlerFactory->isDefinedModel( $contentmodel ) ) {
			throw new LocalizedHttpException(
				new MessageValue( 'rest-bad-content-model', [ $contentmodel ] ), 400
			);
		}

		// Use a known good CSRF token if a token is not needed because we are
		// using a method of authentication that protects against CSRF, like OAuth.
		$token = $this->needsToken() ? $this->getToken() : $this->getUser()->getEditToken();

		$params = [
			'action' => 'edit',
			'title' => $title,
			'text' => $body['source'],
			'summary' => $body['comment'],
			'token' => $token
		];

		if ( $contentmodel !== null ) {
			$params['contentmodel'] = $contentmodel;
		}

		if ( $baseRevId > 0 ) {
			$params['baserevid'] = $baseRevId;
			$params['nocreate'] = true;
		} else {
			$params['createonly'] = true;
		}

		return $params;
	}

	/**
	 * @inheritDoc
	 */
	protected function mapActionModuleResult( array $data ) {
		if ( isset( $data['edit']['nochange'] ) ) {
			// Null-edit, no new revision was created. The new revision is the same as the old.
			// We may want to signal this more explicitly to the client in the future.

			$title = $this->titleParser->parseTitle( $this->getValidatedParams()['title'] );
			$currentRev = $this->revisionLookup->getRevisionByTitle( $title );

			$data['edit']['newrevid'] = $currentRev->getId();
			$data['edit']['newtimestamp']
				= MWTimestamp::convert( TS_ISO_8601, $currentRev->getTimestamp() );
		}

		return parent::mapActionModuleResult( $data );
	}

	/**
	 * @inheritDoc
	 */
	protected function throwHttpExceptionForActionModuleError( IApiMessage $msg, $statusCode = 400 ) {
		$code = $msg->getApiCode();

		// Provide a message instructing the client to provide the base revision ID for updates.
		if ( $code === 'articleexists' ) {
			$title = $this->getTitleParameter();
			throw new LocalizedHttpException(
				new MessageValue( 'rest-update-cannot-create-page', [ $title ] ),
				409
			);
		}

		if ( $code === 'editconflict' ) {
			$data = $this->getConflictData();
			throw new LocalizedHttpException( MessageValue::newFromSpecifier( $msg ), 409, $data );
		}

		parent::throwHttpExceptionForActionModuleError( $msg, $statusCode );
	}

	protected function generateResponseSpec( string $method ): array {
		$spec = parent::generateResponseSpec( $method );

		$spec['404'] = [ '$ref' => '#/components/responses/GenericErrorResponse' ];
		return $spec;
	}

	/**
	 * Returns an associative array to be used in the response in the event of edit conflicts.
	 *
	 * The resulting array contains the following keys:
	 * - base: revision ID of the base revision
	 * - current: revision ID of the current revision (new base after resolving the conflict)
	 * - local: the difference between the content submitted and the base revision
	 * - remote: the difference between the latest revision of the page and the base revision
	 *
	 * If the differences cannot be determined, an empty array is returned.
	 *
	 * @return array
	 */
	private function getConflictData() {
		$body = $this->getValidatedBody();
		'@phan-var array $body';
		$baseRevId = $body['latest']['id'] ?? 0;
		$title = $this->titleParser->parseTitle( $this->getValidatedParams()['title'] );

		$baseRev = $this->revisionLookup->getRevisionById( $baseRevId );
		$currentRev = $this->revisionLookup->getRevisionByTitle( $title );

		if ( !$baseRev || !$currentRev ) {
			return [];
		}

		$baseContent = $baseRev->getContent(
			SlotRecord::MAIN,
			RevisionRecord::FOR_THIS_USER,
			$this->getAuthority()
		);
		$currentContent = $currentRev->getContent(
			SlotRecord::MAIN,
			RevisionRecord::FOR_THIS_USER,
			$this->getAuthority()
		);

		if ( !$baseContent || !$currentContent ) {
			return [];
		}

		$model = $body['content_model'] ?: $baseContent->getModel();
		$contentHandler = $this->contentHandlerFactory->getContentHandler( $model );
		$newContent = $contentHandler->unserializeContent( $body['source'] );

		if ( !$baseContent instanceof TextContent
			|| !$currentContent instanceof TextContent
			|| !$newContent instanceof TextContent
		) {
			return [];
		}

		$localDiff = $this->getDiff( $baseContent, $newContent );
		$remoteDiff = $this->getDiff( $baseContent, $currentContent );

		if ( !$localDiff || !$remoteDiff ) {
			return [];
		}

		return [
			'base' => $baseRev->getId(),
			'current' => $currentRev->getId(),
			'local' => $localDiff,
			'remote' => $remoteDiff,
		];
	}

	/**
	 * Returns a text diff encoded as an array, to be included in the response data.
	 *
	 * @param TextContent $from
	 * @param TextContent $to
	 *
	 * @return array|null
	 */
	private function getDiff( TextContent $from, TextContent $to ) {
		if ( !is_callable( $this->jsonDiffFunction ) ) {
			return null;
		}

		$json = ( $this->jsonDiffFunction )( $from->getText(), $to->getText(), 2 );
		return FormatJson::decode( $json, true );
	}

	public function getResponseBodySchemaFileName( string $method ): ?string {
		return 'includes/Rest/Handler/Schema/ExistingPageSource.json';
	}
}