Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.73% covered (warning)
72.73%
56 / 77
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageEditingHandler
72.73% covered (warning)
72.73%
56 / 77
33.33% covered (danger)
33.33%
3 / 9
55.09
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 onNamespaceIsMovable
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
6.40
 onMultiContentSave
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 repoContentSave
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
7
 abstractContentSave
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 onGetUserPermissionsErrors
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
6.97
 getRepoUserPermissionsErrors
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getAbstractUserPermissionsErrors
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 roundTripJson
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
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
12namespace MediaWiki\Extension\WikiLambda\HookHandler;
13
14use MediaWiki\Api\ApiMessage;
15use MediaWiki\Config\Config;
16use MediaWiki\Extension\WikiLambda\AbstractContent\AbstractContentUtils;
17use MediaWiki\Extension\WikiLambda\AbstractContent\AbstractWikiContent;
18use MediaWiki\Extension\WikiLambda\ZObjectContent;
19use MediaWiki\Extension\WikiLambda\ZObjectStore;
20use MediaWiki\Extension\WikiLambda\ZObjectUtils;
21use MediaWiki\Linker\LinkTarget;
22use MediaWiki\Logger\LoggerFactory;
23use MediaWiki\Revision\RenderedRevision;
24use MediaWiki\Revision\SlotRecord;
25use MediaWiki\Title\Title;
26use Psr\Log\LoggerInterface;
27use StatusValue;
28use Wikimedia\Message\MessageSpecifier;
29use Wikimedia\Rdbms\IConnectionProvider;
30use Wikimedia\Rdbms\IReadableDatabase;
31
32class 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}