mediawiki-extensions-Discus.../includes/ApiDiscussionToolsEdit.php

511 lines
17 KiB
PHP

<?php
namespace MediaWiki\Extension\DiscussionTools;
use ApiBase;
use ApiMain;
use ApiUsageException;
use Config;
use ConfigFactory;
use DerivativeContext;
use DerivativeRequest;
use MediaWiki\Extension\DiscussionTools\Hooks\HookUtils;
use MediaWiki\Extension\DiscussionTools\ThreadItem\ContentCommentItem;
use MediaWiki\Extension\VisualEditor\ApiParsoidTrait;
use MediaWiki\Extension\VisualEditor\VisualEditorParsoidClientFactory;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\Revision\RevisionLookup;
use SkinFactory;
use Title;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\TypeDef\StringDef;
use Wikimedia\Parsoid\Utils\DOMCompat;
use Wikimedia\Parsoid\Utils\DOMUtils;
class ApiDiscussionToolsEdit extends ApiBase {
use ApiDiscussionToolsTrait;
use ApiParsoidTrait;
private CommentParser $commentParser;
private VisualEditorParsoidClientFactory $parsoidClientFactory;
private SubscriptionStore $subscriptionStore;
private SkinFactory $skinFactory;
private Config $config;
private RevisionLookup $revisionLookup;
public function __construct(
ApiMain $main,
string $name,
VisualEditorParsoidClientFactory $parsoidClientFactory,
CommentParser $commentParser,
SubscriptionStore $subscriptionStore,
SkinFactory $skinFactory,
ConfigFactory $configFactory,
RevisionLookup $revisionLookup
) {
parent::__construct( $main, $name );
$this->parsoidClientFactory = $parsoidClientFactory;
$this->commentParser = $commentParser;
$this->subscriptionStore = $subscriptionStore;
$this->skinFactory = $skinFactory;
$this->config = $configFactory->makeConfig( 'discussiontools' );
$this->revisionLookup = $revisionLookup;
$this->setLogger( LoggerFactory::getInstance( 'DiscussionTools' ) );
}
/**
* @inheritDoc
* @throws ApiUsageException
*/
public function execute() {
$params = $this->extractRequestParams();
$title = Title::newFromText( $params['page'] );
$result = null;
$autoSubscribe =
$this->config->get( 'DiscussionToolsAutoTopicSubEditor' ) === 'discussiontoolsapi' &&
HookUtils::shouldAddAutoSubscription( $this->getUser(), $title );
$subscribableHeadingName = null;
$subscribableSectionTitle = '';
if ( !$title ) {
$this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['page'] ) ] );
}
$this->getErrorFormatter()->setContextTitle( $title );
$session = null;
$usedFormTokensKey = 'DiscussionTools:usedFormTokens';
$formToken = $params['formtoken'];
if ( $formToken ) {
$session = $this->getContext()->getRequest()->getSession();
$usedFormTokens = $session->get( $usedFormTokensKey ) ?? [];
if ( in_array( $formToken, $usedFormTokens, true ) ) {
$this->dieWithError( [ 'apierror-discussiontools-formtoken-used' ] );
}
}
$this->requireOnlyOneParameter( $params, 'wikitext', 'html' );
if ( $params['paction'] === 'addtopic' ) {
$this->requireAtLeastOneParameter( $params, 'sectiontitle' );
}
// To determine if we need to add a signature,
// preview the comment without adding one and check if the result is signed properly.
$previewResult = $this->previewMessage(
$params['paction'] === 'addtopic' ? 'topic' : 'reply',
$title,
[
'wikitext' => $params['wikitext'],
'html' => $params['html'],
'sectiontitle' => $params['sectiontitle'],
],
$params
);
$previewResultHtml = $previewResult->getResultData( [ 'parse', 'text' ] );
$previewContainer = DOMCompat::getBody( DOMUtils::parseHTML( $previewResultHtml ) );
$previewThreadItemSet = $this->commentParser->parse( $previewContainer, $title->getTitleValue() );
if ( CommentUtils::isSingleCommentSignedBy(
$previewThreadItemSet, $this->getUser()->getName(), $previewContainer
) ) {
$signature = null;
} else {
$signature = $this->msg( 'discussiontools-signature-prefix' )->inContentLanguage()->text() . '~~~~';
}
switch ( $params['paction'] ) {
case 'addtopic':
$wikitext = $params['wikitext'];
$html = $params['html'];
$previewHeading = null;
$previewHeadings = $previewThreadItemSet->getThreads();
if ( count( $previewHeadings ) > 0 && !$previewHeadings[ 0 ]->isPlaceholderHeading() ) {
$previewHeading = $previewHeadings[ 0 ];
}
if ( !$params['allownosectiontitle'] ) {
// Check if the preview HTML starts with a section title. Note that even if the provided
// 'sectiontitle' param is empty, a heading could been included in the message body, and
// that's acceptable (T338390). Heading levels other than the default level 2 are also
// acceptable (T267288).
if ( !$previewHeading ) {
$this->dieWithError( [ 'discussiontools-newtopic-missing-title' ] );
}
}
if ( isset( $params['summary'] ) ) {
$summary = $params['summary'];
} else {
// Generate an edit summary from the heading in the preview HTML, rather than from the
// 'sectiontitle' param like the action=edit API would. This has two benefits:
// * Works when the heading is included in the message body instead of the param (T338390)
// * Works better for complicated markup in the heading, e.g. templates (T335200)
if ( $previewHeading ) {
$sectionTitle = $previewHeading->getLinkableTitle();
$summary = $this->msg( 'newsectionsummary' )->plaintextParams( $sectionTitle )
->inContentLanguage()->text();
} else {
// TODO: Should we generate something here? (T275702)
$summary = '';
}
}
if ( $wikitext !== null ) {
if ( $signature !== null ) {
$wikitext = CommentModifier::appendSignatureWikitext( $wikitext, $signature );
}
} else {
$doc = DOMUtils::parseHTML( '' );
$container = DOMUtils::parseHTMLToFragment( $doc, $html );
if ( $signature !== null ) {
CommentModifier::appendSignature( $container, $signature );
}
$html = DOMUtils::getFragmentInnerHTML( $container );
$wikitext = $this->transformHTML( $title, $html )[ 'body' ];
}
$mobileFormatParams = [];
// Boolean parameters must be omitted completely to be treated as false.
// Param is added by hook in MobileFrontend, so it may be unset.
if ( isset( $params['mobileformat'] ) && $params['mobileformat'] ) {
$mobileFormatParams['mobileformat'] = '1';
}
// As section=new this is append only so we don't need to
// worry about edit-conflict params such as oldid/baserevid/etag.
// Edit summary is also automatically generated when section=new
$context = new DerivativeContext( $this->getContext() );
$context->setRequest(
new DerivativeRequest(
$context->getRequest(),
[
'action' => 'visualeditoredit',
'paction' => 'save',
'page' => $params['page'],
'token' => $params['token'],
'wikitext' => $wikitext,
'summary' => $summary,
'section' => 'new',
'sectiontitle' => $params['sectiontitle'],
'starttimestamp' => wfTimestampNow(),
'useskin' => $params['useskin'],
'watchlist' => $params['watchlist'],
'captchaid' => $params['captchaid'],
'captchaword' => $params['captchaword'],
'nocontent' => $params['nocontent'],
'returnto' => $params['returnto'],
'returntoquery' => $params['returntoquery'],
'returntoanchor' => $params['returntoanchor'],
] + $mobileFormatParams,
/* was posted? */ true
)
);
$api = new ApiMain(
$context,
/* enable write? */ true
);
$api->execute();
$data = $api->getResult()->getResultData();
$result = $data['visualeditoredit'];
if ( $autoSubscribe && isset( $result['content'] ) ) {
// Determining the added topic's name directly is hard (we'd have to ensure we have the
// same timestamp, and replicate some CommentParser stuff). Just pull it out of the response.
$doc = DOMUtils::parseHTML( $result['content'] );
$container = DOMCompat::getBody( $doc );
$threadItemSet = $this->commentParser->parse( $container, $title->getTitleValue() );
$threads = $threadItemSet->getThreads();
if ( count( $threads ) ) {
$lastHeading = end( $threads );
$subscribableHeadingName = $lastHeading->getName();
$subscribableSectionTitle = $lastHeading->getLinkableTitle();
}
}
break;
case 'addcomment':
$this->requireAtLeastOneParameter( $params, 'commentid', 'commentname' );
$commentId = $params['commentid'] ?? null;
$commentName = $params['commentname'] ?? null;
if ( !$title->exists() ) {
// The page does not exist, so the comment we're trying to reply to can't exist either.
if ( $commentId ) {
$this->dieWithError( [ 'apierror-discussiontools-commentid-notfound', $commentId ] );
} else {
$this->dieWithError( [ 'apierror-discussiontools-commentname-notfound', $commentName ] );
}
}
// Fetch the latest revision
$requestedRevision = $this->revisionLookup->getRevisionByTitle( $title );
if ( !$requestedRevision ) {
$this->dieWithError(
[ 'apierror-missingrev-title', wfEscapeWikiText( $title->getPrefixedText() ) ],
'nosuchrevid'
);
}
$response = $this->requestRestbasePageHtml( $requestedRevision );
$headers = $response['headers'];
$doc = DOMUtils::parseHTML( $response['body'] );
// Don't trust RESTBase to always give us the revision we requested,
// instead get the revision ID from the document and use that.
// Ported from ve.init.mw.ArticleTarget.prototype.parseMetadata
$docRevId = null;
$aboutDoc = $doc->documentElement->getAttribute( 'about' );
if ( $aboutDoc ) {
preg_match( '/revision\\/([0-9]+)$/', $aboutDoc, $docRevIdMatches );
if ( $docRevIdMatches ) {
$docRevId = (int)$docRevIdMatches[ 1 ];
}
}
if ( !$docRevId ) {
$this->dieWithError( 'apierror-visualeditor-docserver', 'docserver' );
}
if ( $docRevId !== $requestedRevision->getId() ) {
// TODO: If this never triggers, consider removing the check.
$this->getLogger()->warning(
"Requested revision {$requestedRevision->getId()} " .
"but received {$docRevId}."
);
}
$container = DOMCompat::getBody( $doc );
// Unwrap sections, so that transclusions overlapping section boundaries don't cause all
// comments in the sections to be treated as transcluded from another page.
CommentUtils::unwrapParsoidSections( $container );
$threadItemSet = $this->commentParser->parse( $container, $title->getTitleValue() );
if ( $commentId ) {
$comment = $threadItemSet->findCommentById( $commentId );
if ( !$comment || !( $comment instanceof ContentCommentItem ) ) {
$this->dieWithError( [ 'apierror-discussiontools-commentid-notfound', $commentId ] );
}
} else {
$comments = $threadItemSet->findCommentsByName( $commentName );
$comment = $comments[ 0 ] ?? null;
if ( count( $comments ) > 1 ) {
$this->dieWithError( [ 'apierror-discussiontools-commentname-ambiguous', $commentName ] );
} elseif ( !$comment || !( $comment instanceof ContentCommentItem ) ) {
$this->dieWithError( [ 'apierror-discussiontools-commentname-notfound', $commentName ] );
}
}
if ( $comment->getTranscludedFrom() ) {
// Replying to transcluded comments should not be possible. We check this client-side, but
// the comment might have become transcluded in the meantime (T268069), so check again. We
// didn't have this check before T313100, since usually the reply would just disappear in
// Parsoid, but now it would be placed after the transclusion, which would be wrong.
$this->dieWithError( 'discussiontools-error-comment-not-saved', 'comment-became-transcluded' );
}
if ( $params['wikitext'] !== null ) {
CommentModifier::addWikitextReply( $comment, $params['wikitext'], $signature );
} else {
CommentModifier::addHtmlReply( $comment, $params['html'], $signature );
}
if ( isset( $params['summary'] ) ) {
$summary = $params['summary'];
} else {
$sectionTitle = $comment->getHeading()->getLinkableTitle();
$summary = ( $sectionTitle ? '/* ' . $sectionTitle . ' */ ' : '' ) .
$this->msg( 'discussiontools-defaultsummary-reply' )->inContentLanguage()->text();
}
if ( $autoSubscribe ) {
$heading = $comment->getSubscribableHeading();
if ( $heading ) {
$subscribableHeadingName = $heading->getName();
$subscribableSectionTitle = $heading->getLinkableTitle();
}
}
$context = new DerivativeContext( $this->getContext() );
$context->setRequest(
new DerivativeRequest(
$context->getRequest(),
[
'action' => 'visualeditoredit',
'paction' => 'save',
'page' => $params['page'],
'token' => $params['token'],
'oldid' => $docRevId,
'html' => DOMCompat::getOuterHTML( $doc->documentElement ),
'summary' => $summary,
'baserevid' => $docRevId,
'starttimestamp' => wfTimestampNow(),
'etag' => $headers['etag'] ?? null,
'useskin' => $params['useskin'],
'watchlist' => $params['watchlist'],
'captchaid' => $params['captchaid'],
'captchaword' => $params['captchaword'],
'nocontent' => $params['nocontent'],
'returnto' => $params['returnto'],
'returntoquery' => $params['returntoquery'],
'returntoanchor' => $params['returntoanchor'],
],
/* was posted? */ true
)
);
$api = new ApiMain(
$context,
/* enable write? */ true
);
$api->execute();
// TODO: Tags are only added by 'dttags' existing on the original request
// context (see Hook::onRecentChangeSave). What tags (if any) should be
// added in this API?
$data = $api->getResult()->getResultData();
$result = $data['visualeditoredit'];
break;
}
if ( !isset( $result['newrevid'] ) && isset( $result['result'] ) && $result['result'] === 'success' ) {
// No new revision, so no changes were made to the page (null edit).
// Comment was not actually saved, so for this API, that's an error.
// This should not be possible after T313100.
$this->dieWithError( 'discussiontools-error-comment-not-saved', 'comment-comment-not-saved' );
}
if ( $autoSubscribe && $subscribableHeadingName ) {
$subsTitle = $title->createFragmentTarget( $subscribableSectionTitle );
$this->subscriptionStore
->addAutoSubscriptionForUser( $this->getUser(), $subsTitle, $subscribableHeadingName );
}
// Check the post was successful (could have been blocked by ConfirmEdit) before
// marking the form token as used.
if ( $formToken && isset( $result['result'] ) && $result['result'] === 'success' ) {
$usedFormTokens[] = $formToken;
// Set an arbitrary limit of the number of form tokens to
// store to prevent session storage from becoming full.
// It is unlikely that form tokens other than the few most
// recently used will be needed.
while ( count( $usedFormTokens ) > 50 ) {
// Discard the oldest tokens first
array_shift( $usedFormTokens );
}
$session->set( $usedFormTokensKey, $usedFormTokens );
}
$this->getResult()->addValue( null, $this->getModuleName(), $result );
}
/**
* @inheritDoc
*/
public function getAllowedParams() {
return [
'paction' => [
ParamValidator::PARAM_REQUIRED => true,
ParamValidator::PARAM_TYPE => [
'addcomment',
'addtopic',
],
ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-paction',
ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
],
'page' => [
ParamValidator::PARAM_REQUIRED => true,
ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-page',
],
'token' => [
ParamValidator::PARAM_REQUIRED => true,
],
'formtoken' => [
ParamValidator::PARAM_TYPE => 'string',
StringDef::PARAM_MAX_CHARS => 16,
],
'commentname' => null,
'commentid' => null,
'wikitext' => [
ParamValidator::PARAM_TYPE => 'text',
ParamValidator::PARAM_DEFAULT => null,
],
'html' => [
ParamValidator::PARAM_TYPE => 'text',
ParamValidator::PARAM_DEFAULT => null,
],
'summary' => [
ParamValidator::PARAM_TYPE => 'string',
ParamValidator::PARAM_DEFAULT => null,
ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-summary',
],
'sectiontitle' => [
ParamValidator::PARAM_TYPE => 'string',
],
'allownosectiontitle' => false,
'useskin' => [
ParamValidator::PARAM_TYPE => array_keys( $this->skinFactory->getInstalledSkins() ),
ApiBase::PARAM_HELP_MSG => 'apihelp-parse-param-useskin',
],
'watchlist' => [
ApiBase::PARAM_HELP_MSG => 'apihelp-edit-param-watchlist',
],
'captchaid' => [
ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-captchaid',
],
'captchaword' => [
ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-captchaword',
],
'nocontent' => [
ApiBase::PARAM_HELP_MSG => 'apihelp-visualeditoredit-param-nocontent',
],
'returnto' => [
ParamValidator::PARAM_TYPE => 'title',
ApiBase::PARAM_HELP_MSG => 'apihelp-edit-param-returnto',
],
'returntoquery' => [
ParamValidator::PARAM_TYPE => 'string',
ParamValidator::PARAM_DEFAULT => '',
ApiBase::PARAM_HELP_MSG => 'apihelp-edit-param-returntoquery',
],
'returntoanchor' => [
ParamValidator::PARAM_TYPE => 'string',
ParamValidator::PARAM_DEFAULT => '',
ApiBase::PARAM_HELP_MSG => 'apihelp-edit-param-returntoanchor',
],
];
}
/**
* @inheritDoc
*/
public function needsToken() {
return 'csrf';
}
/**
* @inheritDoc
*/
public function isInternal() {
return true;
}
/**
* @inheritDoc
*/
public function isWriteMode() {
return true;
}
}