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