Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
72.73% |
56 / 77 |
|
33.33% |
3 / 9 |
CRAP | |
0.00% |
0 / 1 |
| PageEditingHandler | |
72.73% |
56 / 77 |
|
33.33% |
3 / 9 |
55.09 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| onNamespaceIsMovable | |
77.78% |
7 / 9 |
|
0.00% |
0 / 1 |
6.40 | |||
| onMultiContentSave | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
5.03 | |||
| repoContentSave | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
7 | |||
| abstractContentSave | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
| onGetUserPermissionsErrors | |
70.00% |
7 / 10 |
|
0.00% |
0 / 1 |
6.97 | |||
| getRepoUserPermissionsErrors | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
| getAbstractUserPermissionsErrors | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
| roundTripJson | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | /** |
| 4 | * WikiLambda handler for hooks which alter page editing |
| 5 | * |
| 6 | * @file |
| 7 | * @ingroup Extensions |
| 8 | * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt |
| 9 | * @license MIT |
| 10 | */ |
| 11 | |
| 12 | namespace MediaWiki\Extension\WikiLambda\HookHandler; |
| 13 | |
| 14 | use MediaWiki\Api\ApiMessage; |
| 15 | use MediaWiki\Config\Config; |
| 16 | use MediaWiki\Extension\WikiLambda\AbstractContent\AbstractContentUtils; |
| 17 | use MediaWiki\Extension\WikiLambda\AbstractContent\AbstractWikiContent; |
| 18 | use MediaWiki\Extension\WikiLambda\ZObjectContent; |
| 19 | use MediaWiki\Extension\WikiLambda\ZObjectStore; |
| 20 | use MediaWiki\Extension\WikiLambda\ZObjectUtils; |
| 21 | use MediaWiki\Linker\LinkTarget; |
| 22 | use MediaWiki\Logger\LoggerFactory; |
| 23 | use MediaWiki\Revision\RenderedRevision; |
| 24 | use MediaWiki\Revision\SlotRecord; |
| 25 | use MediaWiki\Title\Title; |
| 26 | use Psr\Log\LoggerInterface; |
| 27 | use StatusValue; |
| 28 | use Wikimedia\Message\MessageSpecifier; |
| 29 | use Wikimedia\Rdbms\IConnectionProvider; |
| 30 | use Wikimedia\Rdbms\IReadableDatabase; |
| 31 | |
| 32 | class PageEditingHandler implements |
| 33 | \MediaWiki\Title\Hook\NamespaceIsMovableHook, |
| 34 | \MediaWiki\Storage\Hook\MultiContentSaveHook, |
| 35 | \MediaWiki\Permissions\Hook\GetUserPermissionsErrorsHook |
| 36 | { |
| 37 | private IReadableDatabase $dbr; |
| 38 | |
| 39 | private LoggerInterface $logger; |
| 40 | |
| 41 | public function __construct( |
| 42 | private readonly Config $config, |
| 43 | IConnectionProvider $dbProvider, |
| 44 | private readonly ZObjectStore $zObjectStore |
| 45 | |
| 46 | ) { |
| 47 | $this->dbr = $dbProvider->getReplicaDatabase(); |
| 48 | |
| 49 | $this->logger = LoggerFactory::getInstance( 'WikiLambda' ); |
| 50 | } |
| 51 | |
| 52 | /** |
| 53 | * @see https://www.mediawiki.org/wiki/Manual:Hooks/NamespaceIsMovable |
| 54 | * @inheritDoc |
| 55 | */ |
| 56 | public function onNamespaceIsMovable( $index, &$result ) { |
| 57 | // For Repo Mode: |
| 58 | if ( $this->config->get( 'WikiLambdaEnableRepoMode' ) ) { |
| 59 | // If Repo Mode is enabled, NS_MAIN will always be ZObject content |
| 60 | if ( $index === NS_MAIN ) { |
| 61 | $result = false; |
| 62 | // Over-ride any other extensions which might have other ideas |
| 63 | return false; |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | // For Abstract Mode: |
| 68 | if ( $this->config->get( 'WikiLambdaEnableAbstractMode' ) ) { |
| 69 | foreach ( $this->config->get( 'WikiLambdaAbstractNamespaces' ) as $configuredIndex ) { |
| 70 | if ( $index === $configuredIndex ) { |
| 71 | // NOTE: If we want to later support moving abstract content pages (e.g. draft-to-main), we'll |
| 72 | // need to adjust this. |
| 73 | $result = false; |
| 74 | // Over-ride any other extensions which might have other ideas |
| 75 | return false; |
| 76 | } |
| 77 | } |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | /** |
| 82 | * @see https://www.mediawiki.org/wiki/Manual:Hooks/MultiContentSave |
| 83 | * @inheritDoc |
| 84 | */ |
| 85 | public function onMultiContentSave( $renderedRevision, $user, $summary, $flags, $hookStatus ) { |
| 86 | // Abstract Mode is enabled |
| 87 | if ( $this->config->get( 'WikiLambdaEnableAbstractMode' ) ) { |
| 88 | $linkTarget = $renderedRevision->getRevision()->getPageAsLinkTarget(); |
| 89 | |
| 90 | $configuredNamespaces = array_keys( $this->config->get( 'WikiLambdaAbstractNamespaces' ) ); |
| 91 | |
| 92 | // If namespace is one of the Abstract Namespaces, check for title and content type |
| 93 | if ( in_array( $linkTarget->getNamespace(), $configuredNamespaces, true ) ) { |
| 94 | return $this->abstractContentSave( $linkTarget, $renderedRevision, $hookStatus ); |
| 95 | } |
| 96 | // Abstract Mode but not an Abstract namespace: not our content |
| 97 | } |
| 98 | |
| 99 | // Repo Mode is enabled |
| 100 | if ( $this->config->get( 'WikiLambdaEnableRepoMode' ) ) { |
| 101 | $linkTarget = $renderedRevision->getRevision()->getPageAsLinkTarget(); |
| 102 | |
| 103 | // If namespace is Main (ZObjects) check title, content type and validity: |
| 104 | if ( $linkTarget->inNamespace( NS_MAIN ) ) { |
| 105 | return $this->repoContentSave( $linkTarget, $renderedRevision, $hookStatus ); |
| 106 | } |
| 107 | // Repo Mode but not Main namespace: not our content |
| 108 | } |
| 109 | |
| 110 | // Nothing for us to do |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * Given a page being saved on Repo Enabled mode and in the Main namespace, |
| 115 | * this method makes sure that: |
| 116 | * * the title is well formed (is a ZObject Id), |
| 117 | * * the content is of the right kind (ZObjectContent), |
| 118 | * * the content passes validation checks, and |
| 119 | * * the labels don't clash with existing ones |
| 120 | * |
| 121 | * @param LinkTarget $linkTarget |
| 122 | * @param RenderedRevision $renderedRevision |
| 123 | * @param StatusValue $hookStatus |
| 124 | * @return bool |
| 125 | */ |
| 126 | private function repoContentSave( $linkTarget, $renderedRevision, $hookStatus ): bool { |
| 127 | $zid = $linkTarget->getDBkey(); |
| 128 | if ( !ZObjectUtils::isValidZObjectReference( $zid ) ) { |
| 129 | // Title not valid; exit with error |
| 130 | $hookStatus->fatal( 'wikilambda-invalidzobjecttitle', $zid ); |
| 131 | return false; |
| 132 | } |
| 133 | |
| 134 | $content = $renderedRevision->getRevision()->getSlots()->getContent( SlotRecord::MAIN ); |
| 135 | |
| 136 | if ( !( $content instanceof ZObjectContent ) ) { |
| 137 | // Not the right type of content; exit with error |
| 138 | $hookStatus->fatal( 'wikilambda-invalidcontenttype' ); |
| 139 | return false; |
| 140 | } |
| 141 | |
| 142 | if ( !$content->isValid() ) { |
| 143 | // Repo content not valid; exit with error |
| 144 | $hookStatus->fatal( 'wikilambda-invalidzobject' ); |
| 145 | return false; |
| 146 | } |
| 147 | |
| 148 | // (T260751) Ensure uniqueness of type / label / language triples on save. |
| 149 | $newLabels = $content->getLabels()->getValueAsList(); |
| 150 | |
| 151 | if ( $newLabels === [] ) { |
| 152 | // Unlabelled; don't error. |
| 153 | return true; |
| 154 | } |
| 155 | |
| 156 | $clashes = $this->zObjectStore->findZObjectLabelConflicts( |
| 157 | $zid, |
| 158 | $content->getZType(), |
| 159 | $newLabels |
| 160 | ); |
| 161 | |
| 162 | if ( $clashes === [] ) { |
| 163 | // No clashes; success |
| 164 | return true; |
| 165 | } |
| 166 | |
| 167 | // Label clashes found; exit with error |
| 168 | foreach ( $clashes as $language => $clash_zid ) { |
| 169 | $hookStatus->fatal( 'wikilambda-labelclash', $clash_zid, $language ); |
| 170 | } |
| 171 | return false; |
| 172 | } |
| 173 | |
| 174 | /** |
| 175 | * Given a page being saved on Abstract Enabled mode, and in an Abstract namespace, |
| 176 | * this method makes sure that: |
| 177 | * * the title is well formed (is a Wikidata Item Qid), and |
| 178 | * * the content is of the right kind (AbstractWikiContent). |
| 179 | * |
| 180 | * @param LinkTarget $linkTarget |
| 181 | * @param RenderedRevision $renderedRevision |
| 182 | * @param StatusValue $hookStatus |
| 183 | * @return bool |
| 184 | */ |
| 185 | private function abstractContentSave( $linkTarget, $renderedRevision, $hookStatus ): bool { |
| 186 | $qid = $linkTarget->getDBkey(); |
| 187 | if ( !AbstractContentUtils::isValidWikidataItemReference( $qid ) ) { |
| 188 | // Title not valid; exit with error |
| 189 | $hookStatus->fatal( 'wikilambda-abstract-error-invalid-title', $qid ); |
| 190 | return false; |
| 191 | } |
| 192 | |
| 193 | $content = $renderedRevision->getRevision()->getSlots()->getContent( SlotRecord::MAIN ); |
| 194 | |
| 195 | if ( !( $content instanceof AbstractWikiContent ) ) { |
| 196 | // Not the right type of content; exit with error |
| 197 | $hookStatus->fatal( 'wikilambda-abstract-error-invalid-content' ); |
| 198 | return false; |
| 199 | } |
| 200 | |
| 201 | // Initial checks passed for Abstract Content; |
| 202 | // Final checks on AbstractWikiContent validity will be done later, when |
| 203 | // PageUpdater::makeNewRevision calls ContentHandler::validateSave |
| 204 | return true; |
| 205 | } |
| 206 | |
| 207 | /** |
| 208 | * @see https://www.mediawiki.org/wiki/Manual:Hooks/getUserPermissionsErrors |
| 209 | * @inheritDoc |
| 210 | */ |
| 211 | public function onGetUserPermissionsErrors( $title, $user, $action, &$result ) { |
| 212 | // TODO (T362234): Is there a nicer way of getting 'all change actions'? |
| 213 | $knownBlockedActions = [ 'create', 'edit', 'upload' ]; |
| 214 | if ( !in_array( $action, $knownBlockedActions, true ) ) { |
| 215 | // Not an action we care about; nothing for us to do. |
| 216 | return; |
| 217 | } |
| 218 | |
| 219 | // Repo Mode is enabled |
| 220 | if ( $this->config->get( 'WikiLambdaEnableRepoMode' ) ) { |
| 221 | if ( $title->inNamespace( NS_MAIN ) ) { |
| 222 | // Main namespace; check for errors in Repo content and exit |
| 223 | return $this->getRepoUserPermissionsErrors( $title, $result ); |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | // Abstract Mode is enabled |
| 228 | if ( $this->config->get( 'WikiLambdaEnableAbstractMode' ) ) { |
| 229 | $configuredNamespaces = array_keys( $this->config->get( 'WikiLambdaAbstractNamespaces' ) ); |
| 230 | if ( in_array( $title->getNamespace(), $configuredNamespaces, true ) ) { |
| 231 | // Abstract Wiki namespace; check for errors in Abstract content and exit |
| 232 | return $this->getAbstractUserPermissionsErrors( $title, $result ); |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | // Nothing for us to do |
| 237 | } |
| 238 | |
| 239 | /** |
| 240 | * For change actions over Repo content (Repo mode enabled, and Main namespace), |
| 241 | * check title validity. |
| 242 | * |
| 243 | * NOTE: We don't do per-user rights checks here; that's left to ZObjectAuthorization |
| 244 | * |
| 245 | * @param Title $title Title being checked against |
| 246 | * @param array|string|MessageSpecifier &$result User permissions error to add. |
| 247 | * @return bool|void True or no return value to continue or false to abort |
| 248 | */ |
| 249 | private function getRepoUserPermissionsErrors( $title, &$result ) { |
| 250 | $zid = $title->getDBkey(); |
| 251 | |
| 252 | if ( !ZObjectUtils::isValidZObjectReference( $zid ) ) { |
| 253 | // ZObject content, but title is not a valid Zid; return error |
| 254 | $result = ApiMessage::create( |
| 255 | wfMessage( 'wikilambda-invalidzobjecttitle', $zid ), |
| 256 | 'wikilambda-invalidzobjecttitle' |
| 257 | ); |
| 258 | return false; |
| 259 | } |
| 260 | |
| 261 | return true; |
| 262 | } |
| 263 | |
| 264 | /** |
| 265 | * For change actions over Abstract content (Abstract mode enabled, and Abstract namespace), |
| 266 | * check title validity. |
| 267 | * |
| 268 | * @param Title $title Title being checked against |
| 269 | * @param array|string|MessageSpecifier &$result User permissions error to add. |
| 270 | * @return bool|void True or no return value to continue or false to abort |
| 271 | */ |
| 272 | private function getAbstractUserPermissionsErrors( $title, &$result ) { |
| 273 | $qid = $title->getDBkey(); |
| 274 | |
| 275 | if ( !AbstractContentUtils::isValidWikidataItemReference( $qid ) ) { |
| 276 | // Abstract Wiki content, but title is not a Wikidata Item Id; return error |
| 277 | $result = ApiMessage::create( wfMessage( 'wikilambda-abstract-error-invalid-title', $qid ) ); |
| 278 | return false; |
| 279 | } |
| 280 | |
| 281 | return true; |
| 282 | } |
| 283 | |
| 284 | /** |
| 285 | * Utility function to round-trip data through JSON encoding/decoding |
| 286 | * |
| 287 | * @param mixed $data |
| 288 | * @return array |
| 289 | */ |
| 290 | private function roundTripJson( $data ): array { |
| 291 | return json_decode( json_encode( $data ), true ); |
| 292 | } |
| 293 | |
| 294 | } |