Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 287
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialWikiSets
0.00% covered (danger)
0.00%
0 / 287
0.00% covered (danger)
0.00%
0 / 14
4970
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
 getDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
182
 buildMainView
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 buildSetView
0.00% covered (danger)
0.00%
0 / 71
0.00% covered (danger)
0.00%
0 / 1
380
 buildTypeSelector
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 buildTableByList
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 buildDeleteView
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 addEntry
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 doSubmit
0.00% covered (danger)
0.00%
0 / 99
0.00% covered (danger)
0.00%
0 / 1
272
 doDelete
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 showLogFragment
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 showNoPermissionsView
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\CentralAuth\Special;
4
5use LogEventsList;
6use LogPage;
7use ManualLogEntry;
8use MediaWiki\Extension\CentralAuth\CentralAuthWikiListService;
9use MediaWiki\Extension\CentralAuth\WikiSet;
10use MediaWiki\Html\Html;
11use MediaWiki\MainConfigNames;
12use MediaWiki\SpecialPage\SpecialPage;
13use MediaWiki\Title\Title;
14use MediaWiki\Xml\Xml;
15use MediaWiki\Xml\XmlSelect;
16use PermissionsError;
17
18/**
19 * Special page to allow to edit "wikisets" which are used to restrict
20 * specific global group permissions to certain wikis.
21 *
22 * @file
23 * @ingroup Extensions
24 */
25
26class SpecialWikiSets extends SpecialPage {
27
28    /** @var bool */
29    private $mCanEdit;
30
31    private CentralAuthWikiListService $wikiListService;
32
33    public function __construct( CentralAuthWikiListService $wikiListService ) {
34        parent::__construct( 'WikiSets' );
35
36        $this->wikiListService = $wikiListService;
37    }
38
39    /**
40     * @inheritDoc
41     */
42    public function getDescription() {
43        return $this->msg( 'centralauth-editset' );
44    }
45
46    /**
47     * @param string|null $subpage
48     * @return void
49     */
50    public function execute( $subpage ) {
51        $this->mCanEdit = $this->getContext()->getAuthority()->isAllowed( 'globalgrouppermissions' );
52        $req = $this->getRequest();
53        $tokenOk = $req->wasPosted()
54            && $this->getUser()->matchEditToken( $req->getVal( 'wpEditToken' ) );
55
56        $this->setHeaders();
57
58        if ( $subpage === null ) {
59            $this->buildMainView();
60            return;
61        }
62
63        if ( str_starts_with( $subpage, 'delete/' ) ) {
64            if ( !$this->mCanEdit ) {
65                $this->showNoPermissionsView();
66            }
67
68            // Remove delete/ part
69            $subpage = substr( $subpage, 7 );
70
71            if ( is_numeric( $subpage ) ) {
72                if ( $tokenOk ) {
73                    $this->doDelete( $subpage );
74                    return;
75                }
76
77                $this->buildDeleteView( $subpage );
78                return;
79            }
80        }
81
82        $set = null;
83        if ( $subpage !== '0' ) {
84            $set = is_numeric( $subpage ) ? WikiSet::newFromId( $subpage ) : WikiSet::newFromName( $subpage );
85            if ( !$set ) {
86                $this->getOutput()->setPageTitleMsg( $this->msg( 'error' ) );
87                $error = $this->msg( 'centralauth-editset-notfound', $subpage )->escaped();
88                $this->buildMainView( Html::errorBox( $error ) );
89                return;
90            }
91        } elseif ( !$this->mCanEdit ) {
92            $this->showNoPermissionsView();
93        }
94
95        if ( $tokenOk ) {
96            if ( !$this->mCanEdit ) {
97                $this->showNoPermissionsView();
98            }
99
100            $this->doSubmit( $set );
101            return;
102        }
103
104        $this->buildSetView( $set );
105    }
106
107    /**
108     * @param string|null $msg Output directly as HTML. Caller must escape.
109     */
110    private function buildMainView( ?string $msg = null ) {
111        // Give grep a chance to find the usages: centralauth-editset-legend-rw,
112        // centralauth-editset-legend-ro
113        $msgPostfix = $this->mCanEdit ? 'rw' : 'ro';
114        $legend = $this->msg( "centralauth-editset-legend-{$msgPostfix}" )->escaped();
115        $this->getOutput()->addHTML( "<fieldset><legend>{$legend}</legend>" );
116        if ( $msg ) {
117            $this->getOutput()->addHTML( $msg );
118        }
119        // Give grep a chance to find the usages: centralauth-editset-intro-rw,
120        // centralauth-editset-intro-ro
121        $this->getOutput()->addWikiMsg( "centralauth-editset-intro-{$msgPostfix}" );
122        $this->getOutput()->addHTML( '<ul>' );
123
124        // Give grep a chance to find the usages: centralauth-editset-item-rw,
125        // centralauth-editset-item-ro
126        foreach ( WikiSet::getAllWikiSets() as $set ) {
127            $text = $this->msg( "centralauth-editset-item-{$msgPostfix}",
128                $set->getName(), $set->getID() )->parse();
129            $this->getOutput()->addHTML( "<li>{$text}</li>" );
130        }
131
132        if ( $this->mCanEdit ) {
133            $target = $this->getPageTitle( '0' );
134            $newlink = $this->getLinkRenderer()->makeLink(
135                $target,
136                $this->msg( 'centralauth-editset-new' )->text()
137            );
138            $this->getOutput()->addHTML( "<li>{$newlink}</li>" );
139        }
140
141        $this->getOutput()->addHTML( '</ul></fieldset>' );
142    }
143
144    /**
145     * @param WikiSet|null $set wiki set to operate on
146     * @param bool|string $error False or raw html to output as error
147     * @param string|null $name (Optional) Name of WikiSet
148     * @param string|null $type WikiSet::OPTIN or WikiSet::OPTOUT
149     * @param string[]|null $wikis
150     * @param string|null $reason
151     */
152    private function buildSetView(
153        ?WikiSet $set, $error = false, $name = null, $type = null, $wikis = null, $reason = null
154    ) {
155        $this->getOutput()->addBacklinkSubtitle( $this->getPageTitle() );
156
157        if ( !$name ) {
158            $name = $set ? $set->getName() : '';
159        }
160        if ( !$type ) {
161            $type = $set ? $set->getType() : WikiSet::OPTIN;
162        }
163        if ( !$wikis ) {
164            $wikis = $set ? $set->getWikisRaw() : [];
165        }
166
167        sort( $wikis );
168        $wikis = implode( "\n", $wikis );
169
170        $url = $this->getPageTitle( (string)( $set ? $set->getId() : 0 ) )
171            ->getLocalUrl();
172
173        if ( $this->mCanEdit ) {
174            // Give grep a chance to find the usages:
175            // centralauth-editset-legend-edit, centralauth-editset-legend-new
176            $legend = $this->msg(
177                'centralauth-editset-legend-' . ( $set ? 'edit' : 'new' ),
178                $name
179            )->escaped();
180        } else {
181            $legend = $this->msg( 'centralauth-editset-legend-view', $name )->escaped();
182        }
183
184        $this->getOutput()->addHTML( "<fieldset><legend>{$legend}</legend>" );
185
186        if ( $set ) {
187            $groups = $set->getRestrictedGroups();
188            if ( $groups ) {
189                $usage = "<ul>\n";
190                foreach ( $groups as $group ) {
191                    $usage .= "<li>" . $this->msg( 'centralauth-editset-grouplink', $group )
192                        ->parse() . "</li>\n";
193                }
194                $usage .= "</ul>";
195            } else {
196                $usage = $this->msg( 'centralauth-editset-nouse' )->parseAsBlock();
197            }
198            $sortedWikis = $set->getWikisRaw();
199            sort( $sortedWikis );
200        } else {
201            $usage = '';
202            $sortedWikis = [];
203        }
204
205        # Make an array of the opposite list of wikis
206        # (all databases *excluding* the defined ones)
207        $restWikis = [];
208        foreach ( $this->getConfig()->get( MainConfigNames::LocalDatabases ) as $wiki ) {
209            if ( !in_array( $wiki, $sortedWikis ) ) {
210                $restWikis[] = $wiki;
211            }
212        }
213        sort( $restWikis );
214
215        if ( $this->mCanEdit ) {
216            if ( $error ) {
217                $this->getOutput()->addHTML( Html::errorBox( $error ) );
218            }
219            $this->getOutput()->addHTML(
220                Html::openElement(
221                    'form',
222                    [ 'action' => $url, 'method' => 'POST' ]
223                )
224            );
225
226            $form = [];
227            $form['centralauth-editset-name'] = Xml::input( 'wpName', false, $name );
228            if ( $usage ) {
229                $form['centralauth-editset-usage'] = $usage;
230            }
231            $form['centralauth-editset-type'] = $this->buildTypeSelector( 'wpType', $type );
232            $form['centralauth-editset-wikis'] = Xml::textarea( 'wpWikis', $wikis );
233            $form['centralauth-editset-restwikis'] = Xml::textarea( 'wpRestWikis',
234                implode( "\n", $restWikis ), 40, 5, [ 'readonly' => true ] );
235            $form['centralauth-editset-reason'] = Xml::input( 'wpReason', 50, $reason ?? '' );
236
237            $this->getOutput()->addHTML( Xml::buildForm( $form, 'centralauth-editset-submit' ) );
238
239            $edittoken = Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() );
240            $this->getOutput()->addHTML( "<p>{$edittoken}</p></form></fieldset>" );
241        } else {
242            // Give grep a chance to find the usages: centralauth-editset-optin,
243            // centralauth-editset-optout
244            $form = [];
245            $form['centralauth-editset-name'] = htmlspecialchars( $name );
246            $form['centralauth-editset-usage'] = $usage;
247            $form['centralauth-editset-type'] = $this->msg( "centralauth-editset-{$type}" )
248                ->escaped();
249            $form['centralauth-editset-wikis'] = self::buildTableByList(
250                $sortedWikis, 3, [ 'style' => 'width:100%;' ]
251            ) . '<hr>';
252            $form['centralauth-editset-restwikis'] = self::buildTableByList(
253                $restWikis, 3, [ 'style' => 'width:100%;' ]
254            );
255
256            $this->getOutput()->addHTML( Xml::buildForm( $form ) );
257        }
258
259        if ( $set ) {
260            $this->showLogFragment( (string)$set->getId() );
261        }
262    }
263
264    /**
265     * @param string $name
266     * @param string $value
267     * @return string
268     */
269    private function buildTypeSelector( $name, $value ) {
270        // Give grep a chance to find the usages: centralauth-editset-optin,
271        // centralauth-editset-optout
272        $select = new XmlSelect( $name, 'set-type', $value );
273        foreach ( [ WikiSet::OPTIN, WikiSet::OPTOUT ] as $type ) {
274            $select->addOption( $this->msg( "centralauth-editset-{$type}" )->text(), $type );
275        }
276        return $select->getHTML();
277    }
278
279    /**
280     * Builds a table of several columns, and divides the items of
281     * $list equally among each column. All items are escaped.
282     *
283     * Could in the future be replaced by CSS column-count.
284     *
285     * @param string[] $list
286     * @param int $columns number of columns
287     * @param array $tableAttribs <table> attributes
288     * @return string Table
289     */
290    private function buildTableByList( array $list, int $columns = 2, array $tableAttribs = [] ): string {
291        $count = count( $list );
292        if ( $count === 0 ) {
293            return $this->msg( 'centralauth-editset-nowikis' )->parse();
294        }
295
296        # If there are less items than columns, limit the number of columns
297        $columns = $count < $columns ? $count : $columns;
298        $itemsPerCol = (int)ceil( $count / $columns );
299        $splitLists = array_chunk( $list, $itemsPerCol );
300
301        $body = '';
302        foreach ( $splitLists as $splitList ) {
303            $body .= '<td style="width:' . round( 100 / $columns ) . '%;"><ul>';
304            foreach ( $splitList as $listitem ) {
305                $body .= Html::element( 'li', [], $listitem );
306            }
307            $body .= '</ul></td>';
308        }
309        return Html::rawElement( 'table', $tableAttribs,
310            '<tbody>' .
311                Html::rawElement( 'tr', [ 'style' => 'vertical-align:top;' ], $body ) .
312            '</tbody>'
313        );
314    }
315
316    /**
317     * @param string $subpage
318     */
319    private function buildDeleteView( $subpage ) {
320        $this->getOutput()->addBacklinkSubtitle( $this->getPageTitle() );
321
322        $set = WikiSet::newFromID( $subpage );
323        if ( !$set ) {
324            $this->buildMainView( Html::errorBox( $this->msg( 'centralauth-editset-notfound', $subpage )->escaped() ) );
325            return;
326        }
327
328        $legend = $this->msg( 'centralauth-editset-legend-delete', $set->getName() )->text();
329        $form = [ 'centralauth-editset-reason' => Xml::input( 'wpReason' ) ];
330        $url = $this->getPageTitle( 'delete/' . $subpage )->getLocalUrl();
331        $edittoken = Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() );
332
333        $this->getOutput()->addHTML(
334            Html::openElement( 'fieldset' ) .
335            Html::element( 'legend', [], $legend ) .
336            Html::openElement( 'form', [ 'action' => $url, 'method' => 'post' ] )
337        );
338        $this->getOutput()->addHTML( Xml::buildForm( $form, 'centralauth-editset-submit-delete' ) );
339        $this->getOutput()->addHTML( "<p>{$edittoken}</p></form></fieldset>" );
340    }
341
342    /**
343     * Log action to 'gblrights' log
344     *
345     * @param string $action Type of action
346     * @param Title $title
347     * @param string $reason
348     * @param array $params
349     */
350    private function addEntry( $action, $title, $reason, $params ): void {
351        $entry = new ManualLogEntry( 'gblrights', $action );
352        $entry->setTarget( $title );
353        $entry->setPerformer( $this->getUser() );
354        $entry->setComment( $reason );
355        $entry->setParameters( $params );
356        $logid = $entry->insert();
357        $entry->publish( $logid );
358    }
359
360    /**
361     * @param WikiSet|null $set wiki set to operate on
362     */
363    private function doSubmit( ?WikiSet $set ) {
364        $name = $this->getContentLanguage()->ucfirst( $this->getRequest()->getVal( 'wpName' ) );
365        $type = $this->getRequest()->getVal( 'wpType' );
366        $wikis = array_unique( preg_split(
367            '/(\s+|\s*\W\s*)/', $this->getRequest()->getVal( 'wpWikis' ), -1, PREG_SPLIT_NO_EMPTY )
368        );
369        $reason = $this->getRequest()->getVal( 'wpReason' );
370
371        if ( !Title::newFromText( $name ) ) {
372            $this->buildSetView( $set, $this->msg( 'centralauth-editset-badname' )->escaped(),
373                $name, $type, $wikis, $reason );
374            return;
375        }
376        if ( ( !$set || $set->getName() != $name ) && WikiSet::newFromName( $name ) ) {
377            $this->buildSetView( $set, $this->msg( 'centralauth-editset-setexists' )->escaped(),
378                $name, $type, $wikis, $reason );
379            return;
380        }
381        if ( !in_array( $type, [ WikiSet::OPTIN, WikiSet::OPTOUT ] ) ) {
382            $this->buildSetView( $set, $this->msg( 'centralauth-editset-badtype' )->escaped(),
383                $name, $type, $wikis, $reason );
384            return;
385        }
386        if ( !$wikis ) {
387            $this->buildSetView( $set, $this->msg( 'centralauth-editset-zerowikis' )->escaped(),
388                $name, $type, $wikis, $reason );
389            return;
390        }
391
392        $badwikis = [];
393        $allwikis = $this->wikiListService->getWikiList();
394        foreach ( $wikis as $wiki ) {
395            if ( !in_array( $wiki, $allwikis ) ) {
396                $badwikis[] = $wiki;
397            }
398        }
399        if ( $badwikis ) {
400            $this->buildSetView( $set, $this->msg(
401                'centralauth-editset-badwikis',
402                implode( ', ', $badwikis ) )
403                ->numParams( count( $badwikis ) )
404                ->escaped(),
405                $name, $type, $wikis, $reason
406            );
407            return;
408        }
409
410        if ( $set ) {
411            $oldname = $set->getName();
412            $oldtype = $set->getType();
413            $oldwikis = $set->getWikisRaw();
414        } else {
415            $set = new WikiSet();
416            $oldname = $oldtype = null;
417            $oldwikis = [];
418        }
419        $set->setName( $name );
420        $set->setType( $type );
421        $set->setWikisRaw( $wikis );
422        $set->saveToDB();
423
424        // Now logging
425        $title = $this->getPageTitle( (string)$set->getID() );
426        if ( !$oldname ) {
427            // New set
428            $this->addEntry(
429                'newset',
430                $title,
431                $reason,
432                [
433                    '4::name' => $name,
434                    '5::type' => $type,
435                    'wikis' => $wikis,
436                ]
437            );
438        } else {
439            if ( $oldname != $name ) {
440                $this->addEntry(
441                    'setrename',
442                    $title,
443                    $reason,
444                    [
445                        '4::name' => $name,
446                        '5::oldName' => $oldname,
447                    ]
448                );
449            }
450            if ( $oldtype != $type ) {
451                $this->addEntry(
452                    'setnewtype',
453                    $title,
454                    $reason,
455                    [
456                        '4::name' => $name,
457                        '5::oldType' => $oldtype,
458                        '6::type' => $type,
459                    ]
460                );
461            }
462            $added = array_diff( $wikis, $oldwikis );
463            $removed = array_diff( $oldwikis, $wikis );
464            if ( $added || $removed ) {
465                $this->addEntry(
466                    'setchange',
467                    $title,
468                    $reason,
469                    [
470                        '4::name' => $name,
471                        'added' => $added,
472                        'removed' => $removed,
473                    ]
474                 );
475            }
476        }
477
478        $returnLink = $this->getLinkRenderer()->makeKnownLink(
479            $this->getPageTitle(), $this->msg( 'centralauth-editset-return' )->text() );
480
481        $this->getOutput()->addHTML(
482            Html::successBox( $this->msg( 'centralauth-editset-success' )->escaped() ) .
483            '<p>' . $returnLink . '</p>'
484        );
485    }
486
487    /**
488     * @param string $setId
489     */
490    private function doDelete( $setId ) {
491        $set = WikiSet::newFromID( $setId );
492        if ( !$set ) {
493            $this->buildMainView( Html::errorBox( $this->msg( 'centralauth-editset-notfound', $setId )->escaped() ) );
494            return;
495        }
496
497        $reason = $this->getRequest()->getVal( 'wpReason' );
498        $name = $set->getName();
499        $set->delete();
500
501        $title = $this->getPageTitle( (string)$set->getID() );
502        $this->addEntry( 'deleteset', $title, $reason, [ '4::name' => $name ] );
503
504        $this->buildMainView( Html::successBox( $this->msg( 'centralauth-editset-success-delete' )->escaped() ) );
505    }
506
507    /**
508     * @param string $number
509     */
510    protected function showLogFragment( $number ) {
511        $title = $this->getPageTitle( $number );
512        $logPage = new LogPage( 'gblrights' );
513        $out = $this->getOutput();
514        $out->addHTML( Xml::element( 'h2', null, $logPage->getName()->text() . "\n" ) );
515        LogEventsList::showLogExtract( $out, 'gblrights', $title->getPrefixedText() );
516    }
517
518    /** @inheritDoc */
519    protected function getGroupName() {
520        return 'wiki';
521    }
522
523    /**
524     * @phan-return never
525     * @return void
526     * @throws PermissionsError
527     */
528    private function showNoPermissionsView() {
529        throw new PermissionsError( 'globalgrouppermissions' );
530    }
531}