Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
127 / 127
100.00% covered (success)
100.00%
30 / 30
CRAP
100.00% covered (success)
100.00%
1 / 1
NukeContext
100.00% covered (success)
100.00%
127 / 127
100.00% covered (success)
100.00%
30 / 30
63
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
8
 getAction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTarget
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getPattern
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNamespaces
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLimit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDateFrom
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getDateTo
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getIncludeRedirects
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getIncludeTalkPages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllPages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMinPageSize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMaxPageSize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAssociatedPages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOriginalPages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNukeAccessStatus
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRequestContext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 willUseTemporaryAccounts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasTarget
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasPages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasOriginalPages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validate
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 validatePrompt
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 validateDate
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 getDeleteReason
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
6
 getNukeMaxAge
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getNukeMaxAgeInDays
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRecentChangesMaxAgeInDays
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 calculateSearchNotices
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3namespace MediaWiki\Extension\Nuke;
4
5use DateTime;
6use Exception;
7use MediaWiki\Context\IContextSource;
8use MediaWiki\MainConfigNames;
9use Wikimedia\IPUtils;
10
11/**
12 * Groups all Nuke-related filters and request data into a single object.
13 * This reduces the work involved in keeping track of every single filter
14 * that gets added into Nuke by keeping it all in a central place.
15 */
16class NukeContext {
17
18    /**
19     * The active "action" for the special page. This determines which stage in the Nuke
20     * form we're in. This is one of the `ACTION_*` constants on {@link SpecialNuke}:
21     *  - {@link SpecialNuke::ACTION_PROMPT}
22     *  - {@link SpecialNuke::ACTION_LIST}
23     *  - {@link SpecialNuke::ACTION_CONFIRM}
24     *  - {@link SpecialNuke::ACTION_DELETE}
25     *
26     * @var string
27     */
28    private string $action = SpecialNuke::ACTION_PROMPT;
29
30    /**
31     * The target actor. Can be a username (normal or temporary account) or
32     * IP address. When not provided, this is an empty string.
33     *
34     * @var string
35     */
36    private string $target = '';
37
38    /**
39     * The listed target actor. Used when the action is `delete` or `confirm`, replacing
40     * {@link $target} to ensure that the target from which the pages belong is what's shown
41     * instead of what's on the input box at request time (T380297). When not provided, this
42     * is an empty string. When this is an empty string, it implies the value of $target
43     * should be used.
44     *
45     * @var string
46     */
47    private string $listedTarget = '';
48
49    /**
50     * The title matching pattern. As of 1.44, this is an SQL LIKE pattern, which uses
51     * `%` as the wildcard character. When not provided, this is an empty string.
52     *
53     * @var string
54     */
55    private string $pattern = '';
56
57    /**
58     * An array of namespace IDs where the query will run. When not provided, this is `null`.
59     * When `null`, this implicitly means all namespaces should be included.
60     *
61     * @var int[]|null
62     */
63    private ?array $namespaces = null;
64
65    /**
66     * The maximum number of pages to get. This limit also applies after hooks run; the
67     * final list of pages must never be larger than this value.
68     *
69     * @var int
70     */
71    private int $limit = 500;
72
73    /**
74     * The date from which the Nuke search should be performed. Only page creations after this
75     * value should be returned. When not provided, this is an empty string.
76     *
77     * @var string
78     */
79    private string $dateFrom = '';
80
81    /**
82     * The date to which the Nuke search should be performed. Only page creations before this
83     * value should be returned. When not provided, this is an empty string.
84     *
85     * @var string
86     */
87    private string $dateTo = '';
88
89    /**
90     * Whether to include talk pages in the search. When not provided, this is `false`.
91     *
92     * @var bool
93     */
94    private bool $includeTalkPages = false;
95
96    /**
97     * Whether to include redirects in the search. When not provided, this is `false`.
98     *
99     * @var bool
100     */
101    private bool $includeRedirects = false;
102
103    /**
104     * The list of pages to delete. Only applicable for the `confirm` and `delete` actions.
105     * When not provided, this is an empty array.
106     *
107     * @var string[]
108     */
109    private array $pages = [];
110
111    /**
112     * The list of pages associated with the target to delete. Only applicable for the `confirm`
113     * and `delete` actions. When not provided, this is an empty array.
114     *
115     * @var string[]
116     */
117    private array $associatedPages = [];
118
119    /**
120     * The original list of pages provided to the user. When on the `confirm` and `delete`
121     * actions, this is required to show pages that were deselected by the user during the
122     * `list` action, allowing users to follow up on found but deselected pages. When not
123     * provided, this is an empty array.
124     *
125     * @var string[]
126     */
127    private array $originalPages = [];
128
129    /**
130     * Whether support for temporary accounts is enabled.
131     *
132     * @var bool
133     */
134    private bool $useTemporaryAccounts = false;
135
136    /**
137     * The current status of the user's access to Nuke.
138     *
139     * @var int
140     */
141    private int $nukeAccessStatus = self::NUKE_ACCESS_INTERNAL_ERROR;
142
143    /**
144     * Constants for nuke access status.
145     */
146    public const NUKE_ACCESS_INTERNAL_ERROR = 0;
147    public const NUKE_ACCESS_GRANTED = 1;
148    public const NUKE_ACCESS_NO_PERMISSION = 2;
149    public const NUKE_ACCESS_BLOCKED = 3;
150
151    /**
152     * The minimum size of pages to list, in bytes. This is used to limit the size of the
153     * pages shown to the user. When not provided, this is by default 0 (no limit).
154     *
155     * @var int
156     */
157    private int $minPageSize = 0;
158
159    /**
160     * The maximum size of pages to list, in bytes. This is used to limit the size of the
161     * pages shown to the user. When not provided, this is by default null.
162     *
163     * Negatives are treated as no-ops, so this is what we default to.
164     *
165     * @var int
166     */
167    private int $maxPageSize = -1;
168
169    /**
170     * Originating request context of the query.
171     *
172     * @var IContextSource
173     */
174    private IContextSource $requestContext;
175
176    /**
177     * Create a new NukeContext without transforming most parameters.
178     *
179     * @param array $params
180     */
181    public function __construct( array $params ) {
182        $this->requestContext = $params['requestContext'];
183        $this->useTemporaryAccounts = $params['useTemporaryAccounts'] ?? $this->useTemporaryAccounts;
184
185        $this->action = $params['action'] ?? $this->action;
186        $this->target = $params['target'] ?? $this->target;
187        $this->listedTarget = $params['listedTarget'] ?? $this->listedTarget;
188        $this->pattern = $params['pattern'] ?? $this->pattern;
189        $this->namespaces = $params['namespaces'] ?? $this->namespaces;
190        $this->limit = $params['limit'] ?? $this->limit;
191
192        if ( isset( $params['dateFrom'] ) && $params['dateFrom'] ) {
193            $this->dateFrom = $params['dateFrom'];
194        }
195        if ( isset( $params['dateTo'] ) && $params['dateTo'] ) {
196            $this->dateTo = $params['dateTo'];
197        }
198
199        $this->includeTalkPages = $params['includeTalkPages'] ?? $this->includeTalkPages;
200        $this->includeRedirects = $params['includeRedirects'] ?? $this->includeRedirects;
201
202        $this->pages = $params['pages'] ?? $this->pages;
203        $this->associatedPages = $params['associatedPages'] ?? $this->associatedPages;
204
205        if ( $this->action == 'delete' || $this->action == 'confirm' ) {
206            if ( $params['listedTarget'] ) {
207                $this->listedTarget = $params['listedTarget'];
208            }
209            $this->originalPages = $params['originalPages'] ?? $this->originalPages;
210        }
211
212        $this->nukeAccessStatus = $params['nukeAccessStatus'] ?? $this->nukeAccessStatus;
213
214        $this->minPageSize = $params['minPageSize'];
215        $this->maxPageSize = $params['maxPageSize'];
216    }
217
218    /**
219     * Returns {@link $action}.
220     * @return string
221     */
222    public function getAction(): string {
223        return $this->action;
224    }
225
226    /**
227     * Returns the target of the request: {@link $target} if {@link $action} is "delete" or
228     * "confirm", {@link listedTarget} otherwise.
229     *
230     * @param string|null $action The action to use. Uses {@link $action} by default.
231     * @return string
232     */
233    public function getTarget( ?string $action = null ): string {
234        $action ??= $this->action;
235
236        if ( $action == 'delete' || $action == 'confirm' ) {
237            if ( $this->listedTarget ) {
238                // "target" might be different, if the user typed in a different name before
239                // hitting "Continue". We still want to show the pages from the user currently
240                // shown on the form.
241                return $this->listedTarget;
242            } else {
243                // No provided target. This may be an incomplete request or a test.
244                // Fall back to using $target.
245                return $this->target;
246            }
247        } else {
248            return $this->target;
249        }
250    }
251
252    /**
253     * Returns {@link $pattern}.
254     * @return string
255     */
256    public function getPattern(): string {
257        return $this->pattern;
258    }
259
260    /**
261     * Returns {@link $namespaces}.
262     * @return int[]|null
263     */
264    public function getNamespaces(): ?array {
265        return $this->namespaces;
266    }
267
268    /**
269     * Returns {@link $limit}.
270     * @return int
271     */
272    public function getLimit(): int {
273        return $this->limit;
274    }
275
276    /**
277     * Returns {@link $dateFrom} in DateTime format. The value of `$dateFrom` should first be
278     * validated with {@link validateDate}.
279     *
280     * FIXME: Doc should be changed to throw DateMalformedStringException in PHP 8.3+.
281     *
282     * @return DateTime|null
283     * @throws Exception
284     */
285    public function getDateFrom(): ?DateTime {
286        if ( !$this->dateFrom ) {
287            return null;
288        }
289        return new DateTime( "{$this->dateFrom}T00:00:00Z" );
290    }
291
292    /**
293     * Returns {@link $dateTo} in DateTime format.The value of `$dateFrom` should first be
294     *  validated with {@link validateDate}.
295     *
296     * FIXME: Doc should be changed to throw DateMalformedStringException in PHP 8.3+.
297     *
298     * @return DateTime|null
299     * @throws Exception
300     */
301    public function getDateTo(): ?DateTime {
302        if ( !$this->dateTo ) {
303            return null;
304        }
305        return new DateTime( "{$this->dateTo}T00:00:00Z" );
306    }
307
308    /**
309     * Returns {@link $includeRedirects}.
310     * @return bool
311     */
312    public function getIncludeRedirects(): bool {
313        return $this->includeRedirects;
314    }
315
316    /**
317     * Returns {@link $includeTalkPages}.
318     * @return bool
319     */
320    public function getIncludeTalkPages(): bool {
321        return $this->includeTalkPages;
322    }
323
324    /**
325     * Returns a merger of {@link $pages} and {@link $associatedPages}.
326     * @return string[]
327     */
328    public function getAllPages(): array {
329        return array_merge( $this->getPages(), $this->getAssociatedPages() );
330    }
331
332    /**
333     * Returns {@link $minPageSize}.
334     *
335     * @return int
336     */
337    public function getMinPageSize(): int {
338        return $this->minPageSize;
339    }
340
341    /**
342     * Returns {@link $maxPageSize}.
343     *
344     * @return int
345     */
346    public function getMaxPageSize(): int {
347        return $this->maxPageSize;
348    }
349
350    /**
351     * Returns {@link $pages}.
352     * @return string[]
353     */
354    public function getPages(): array {
355        return $this->pages;
356    }
357
358    /**
359     * Returns {@link $associatedPages}.
360     * @return string[]
361     */
362    public function getAssociatedPages(): array {
363        return $this->associatedPages;
364    }
365
366    /**
367     * Returns {@link $originalPages}.
368     * @return string[]
369     */
370    public function getOriginalPages(): array {
371        return $this->originalPages;
372    }
373
374    /**
375     * Returns {@link $nukeAccessStatus}.
376     * @return int
377     */
378    public function getNukeAccessStatus(): int {
379        return $this->nukeAccessStatus;
380    }
381
382    /**
383     * Returns {@link requestContext}.
384     * @return IContextSource
385     */
386    public function getRequestContext(): IContextSource {
387        return $this->requestContext;
388    }
389
390    /**
391     * Returns {@link $useTemporaryAccounts}.
392     * @return bool
393     */
394    public function willUseTemporaryAccounts(): bool {
395        return $this->useTemporaryAccounts;
396    }
397
398    /**
399     * Returns whether a target was specified.
400     *
401     * @return bool
402     */
403    public function hasTarget(): bool {
404        return $this->target != '';
405    }
406
407    /**
408     * Returns whether this request has pages selected.
409     *
410     * @return bool
411     */
412    public function hasPages(): bool {
413        return count( $this->pages ) > 0;
414    }
415
416    /**
417     * Returns whether this request has pages shown to the user.
418     *
419     * @return bool
420     */
421    public function hasOriginalPages(): bool {
422        return count( $this->originalPages ) > 0;
423    }
424
425    /**
426     * Validate values for all stages of Nuke. Includes filter validation, and validation prior to
427     * running any "confirm"/"delete" stages. Determines what error messages should be shown to
428     * the user. Returns `true` on success, a string value containing the error for failures.
429     *
430     * @return string|true
431     */
432    public function validate() {
433        $promptValidation = $this->validatePrompt();
434        if ( $promptValidation !== true ) {
435            return $promptValidation;
436        }
437
438        if (
439            (
440                // This is a confirm/delete
441                $this->action == SpecialNuke::ACTION_CONFIRM ||
442                $this->action == SpecialNuke::ACTION_DELETE
443            ) &&
444            // No pages were selected or provided.
445            !$this->hasPages()
446        ) {
447            if ( !$this->hasOriginalPages() ) {
448                // No page list was requested. This is an early confirm attempt without having
449                // listed the pages at all. Show the list form again.
450                return $this->requestContext->msg( 'nuke-nolist' )->text();
451            } else {
452                // Pages were not requested but a page list exists. The user did not select any
453                // pages. Show the list form again.
454                return $this->requestContext->msg( 'nuke-noselected' )->text();
455            }
456        }
457
458        return true;
459    }
460
461    /**
462     * Validate values for the "list" or "prompt" stages of Nuke. Determines what error
463     * messages should be shown to the user. Returns `true` on success, a string value containing
464     * the error for failures.
465     *
466     * Any error returned by this function should be something that blocks the search process.
467     *
468     * @return string|true
469     */
470    public function validatePrompt() {
471        $fromValidationResult = $this->validateDate( $this->dateFrom );
472        if ( $fromValidationResult !== true ) {
473            return $fromValidationResult;
474        }
475
476        $toValidationResult = $this->validateDate( $this->dateTo );
477        if ( $toValidationResult !== true ) {
478            return $toValidationResult;
479        }
480
481        return true;
482    }
483
484    /**
485     * Validate a date-related filter. Checks if the date is before the Nuke max age.
486     *
487     * @param string $value The value to validate
488     * @return string|true
489     */
490    protected function validateDate( string $value ) {
491        if ( $value == '' ) {
492            // No value is valid.
493            return true;
494        }
495
496        $now = ( new DateTime() )
497            ->setTime( 0, 0 )
498            ->getTimestamp();
499        $maxAge = $this->getNukeMaxAge();
500
501        try {
502            $timestamp = ( new DateTime( $value . "T00:00:00Z" ) )
503                ->getTimestamp();
504            if ( $timestamp < $now - $maxAge ) {
505                return $this->requestContext->msg(
506                    'nuke-date-limited',
507                    $this->requestContext->getLanguage()->formatTimePeriod( $maxAge, [
508                        'avoid' => 'avoidhours',
509                        'noabbrevs' => true
510                    ] )
511                )->text();
512            }
513        } catch ( \Exception $e ) {
514            // FIXME: This should be changed to use DateMalformedStringException when MediaWiki
515            // begins using PHP 8.3 as a minimum.
516            return $this->requestContext->msg( 'htmlform-date-invalid' )->text();
517        }
518        return true;
519    }
520
521    /**
522     * Get the user-provided deletion reason, or a default deletion reason if one wasn't
523     * provided.
524     *
525     * @return string
526     */
527    public function getDeleteReason(): string {
528        $context = $this->requestContext;
529        $target = $this->target;
530        $request = $context->getRequest();
531
532        if ( $this->useTemporaryAccounts && IPUtils::isValid( $target ) ) {
533            $defaultReason = $context->msg( 'nuke-defaultreason-tempaccount' )
534                ->inContentLanguage()
535                ->text();
536        } else {
537            $defaultReason = $target === ''
538                ? $context->msg( 'nuke-multiplepeople' )->inContentLanguage()->text()
539                : $context->msg( 'nuke-defaultreason', $target )->inContentLanguage()->text();
540        }
541
542        $dropdownSelection = $request->getText( 'wpDeleteReasonList', 'other' );
543        $reasonInput = $request->getText( 'wpReason', $defaultReason );
544
545        if ( $dropdownSelection === 'other' ) {
546            return $reasonInput;
547        } elseif ( $reasonInput !== '' ) {
548            // Entry from drop down menu + additional comment
549            $separator = $context->msg( 'colon-separator' )->inContentLanguage()->text();
550            return $dropdownSelection . $separator . $reasonInput;
551        } else {
552            return $dropdownSelection;
553        }
554    }
555
556    /**
557     * Get the maximum age in seconds that a page can be before it cannot be deleted by Nuke.
558     *
559     * @param bool $useRCMaxAge Whether to use `$wgRCMaxAge` as a fallback.
560     * @return int
561     */
562    public function getNukeMaxAge( bool $useRCMaxAge = true ): int {
563        $maxAge = $this->requestContext->getConfig()->get( NukeConfigNames::MaxAge );
564        // If no Nuke-specific max age was set, this should match the value of `$wgRCMaxAge`.
565        if ( !$maxAge && $useRCMaxAge ) {
566            $maxAge = $this->requestContext->getConfig()->get( MainConfigNames::RCMaxAge );
567        }
568        return $maxAge;
569    }
570
571    /**
572     * Get the maximum age in days that a page can be before it cannot be deleted by Nuke when a username is provided.
573     *
574     * @return float
575     */
576    public function getNukeMaxAgeInDays(): float {
577        $secondsInADay = 86400;
578        return round( $this->requestContext->getConfig()->get( NukeConfigNames::MaxAge ) / $secondsInADay );
579    }
580
581    /**
582     * Get the maximum age in days that a page can be before it cannot be deleted by Nuke when no username is provided.
583     *
584     * @return float
585     */
586    public function getRecentChangesMaxAgeInDays(): float {
587        $secondsInADay = 86400;
588        return round( $this->requestContext->getConfig()->get( MainConfigNames::RCMaxAge ) / $secondsInADay );
589    }
590
591    /**
592     * Calculate any search notices that need to be displayed with the results.
593     * This is based on the search parameters.
594     *
595     * @return string[] Array of i18n strings to display as a search notice
596     */
597    public function calculateSearchNotices(): array {
598        $notices = [];
599
600        // first check if any values are being ignored
601        $ignoringValues = false;
602
603        if ( $this->maxPageSize < 0 ) {
604            // if the maximum is negative, it's invalid
605            // it is allowed to have it be 0,
606            // because a 0-byte page can exist
607            // the QueryBuilder code will ignore negative values
608            $notices[] = "nuke-searchnotice-negmax";
609            $ignoringValues = true;
610        }
611        if ( $this->minPageSize < 0 ) {
612            // if the minimum is negative, then it's not really a minimum
613            // tell the user the QueryBuilder code will ignore it
614            // this is last because we can still return results if the minimum is negative
615            $notices[] = "nuke-searchnotice-negmin";
616            $ignoringValues = true;
617        }
618
619        // if we're not ignoring either, check for incompatibility
620        if ( !$ignoringValues ) {
621            if ( $this->minPageSize > $this->maxPageSize ) {
622                // if the maximum is less than the minimum then
623                // there's no way any results can be returned
624                $notices[] = "nuke-searchnotice-minmorethanmax";
625            }
626        }
627
628        return $notices;
629    }
630
631}