Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
64.34% covered (warning)
64.34%
83 / 129
54.55% covered (warning)
54.55%
6 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiDelete
64.84% covered (warning)
64.84%
83 / 128
54.55% covered (warning)
54.55%
6 / 11
109.52
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 execute
76.19% covered (warning)
76.19%
32 / 42
0.00% covered (danger)
0.00%
0 / 1
15.28
 delete
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
8
 canDeleteFile
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
12
 deleteFile
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
90
 mustBePosted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isWriteMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllowedParams
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
1
 needsToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpUrls
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Copyright © 2007 Roan Kattouw <roan.kattouw@gmail.com>
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 */
8
9namespace MediaWiki\Api;
10
11use MediaWiki\ChangeTags\ChangeTags;
12use MediaWiki\FileRepo\File\File;
13use MediaWiki\FileRepo\RepoGroup;
14use MediaWiki\MainConfigNames;
15use MediaWiki\Page\DeletePage;
16use MediaWiki\Page\DeletePageFactory;
17use MediaWiki\Page\File\FileDeleteForm;
18use MediaWiki\Page\WikiPage;
19use MediaWiki\Status\Status;
20use MediaWiki\Title\Title;
21use MediaWiki\User\Options\UserOptionsLookup;
22use MediaWiki\Watchlist\WatchedItemStoreInterface;
23use MediaWiki\Watchlist\WatchlistManager;
24use StatusValue;
25use Wikimedia\ParamValidator\ParamValidator;
26
27/**
28 * API module that facilitates deleting pages. The API equivalent of action=delete.
29 * Requires API write mode to be enabled.
30 *
31 * @ingroup API
32 */
33class ApiDelete extends ApiBase {
34
35    use ApiWatchlistTrait;
36
37    public function __construct(
38        ApiMain $mainModule,
39        string $moduleName,
40        private readonly RepoGroup $repoGroup,
41        WatchlistManager $watchlistManager,
42        WatchedItemStoreInterface $watchedItemStore,
43        UserOptionsLookup $userOptionsLookup,
44        private readonly DeletePageFactory $deletePageFactory,
45    ) {
46        parent::__construct( $mainModule, $moduleName );
47
48        // Variables needed in ApiWatchlistTrait trait
49        $this->watchlistExpiryEnabled = $this->getConfig()->get( MainConfigNames::WatchlistExpiry );
50        $this->watchlistMaxDuration =
51            $this->getConfig()->get( MainConfigNames::WatchlistExpiryMaxDuration );
52        $this->watchlistManager = $watchlistManager;
53        $this->watchedItemStore = $watchedItemStore;
54        $this->userOptionsLookup = $userOptionsLookup;
55    }
56
57    /**
58     * Extracts the title and reason from the request parameters and invokes
59     * the local delete() function with these as arguments. It does not make use of
60     * the delete function specified by Article.php. If the deletion succeeds, the
61     * details of the article deleted and the reason for deletion are added to the
62     * result object.
63     */
64    public function execute() {
65        $this->useTransactionalTimeLimit();
66
67        $params = $this->extractRequestParams();
68
69        $pageObj = $this->getTitleOrPageId( $params, 'fromdbmaster' );
70        $titleObj = $pageObj->getTitle();
71        $this->getErrorFormatter()->setContextTitle( $titleObj );
72        if ( !$pageObj->exists() &&
73            // @phan-suppress-next-line PhanUndeclaredMethod
74            !( $titleObj->getNamespace() === NS_FILE && self::canDeleteFile( $pageObj->getFile() ) )
75        ) {
76            $this->dieWithError( 'apierror-missingtitle' );
77        }
78
79        $reason = $params['reason'];
80        $user = $this->getUser();
81
82        $tags = $params['tags'] ?: [];
83
84        if ( $titleObj->getNamespace() === NS_FILE ) {
85            $status = $this->deleteFile(
86                $pageObj,
87                $params['oldimage'],
88                $reason,
89                false,
90                $tags,
91                $params['deletetalk']
92            );
93            // TODO What kind of non-fatal errors should we expect here?
94            $wasScheduled = $status->isOK() && $status->getValue() === false;
95        } else {
96            $status = $this->delete( $pageObj, $reason, $tags, $params['deletetalk'] );
97            $wasScheduled = $status->isGood() && $status->getValue() === false;
98        }
99
100        if ( !$status->isOK() ) {
101            $this->dieStatus( $status );
102        }
103
104        if ( $wasScheduled ) {
105            $this->addWarning( [ 'delete-scheduled', $titleObj->getPrefixedText() ] );
106        }
107
108        // Deprecated parameters
109        if ( $params['watch'] ) {
110            $watch = 'watch';
111        } elseif ( $params['unwatch'] ) {
112            $watch = 'unwatch';
113        } else {
114            $watch = $params['watchlist'];
115        }
116
117        $watchlistExpiry = $this->getExpiryFromParams( $params, $titleObj, $user );
118        $this->setWatch( $watch, $titleObj, $user, 'watchdeletion', $watchlistExpiry );
119
120        $r = [
121            'title' => $titleObj->getPrefixedText(),
122            'reason' => $reason,
123        ];
124
125        // TODO: We could expose additional information (scheduled and log ID) about the status of the talk page
126        // deletion.
127        if ( $wasScheduled ) {
128            $r['scheduled'] = true;
129        } else {
130            // Scheduled deletions don't currently have a log entry available at this point
131            $r['logid'] = $status->value;
132        }
133        $this->getResult()->addValue( null, $this->getModuleName(), $r );
134    }
135
136    /**
137     * We have our own delete() function, since Article.php's implementation is split in two phases
138     *
139     * @param WikiPage $page WikiPage object to work on
140     * @param string|null &$reason Reason for the deletion. Autogenerated if null
141     * @param string[] $tags Tags to tag the deletion with
142     * @param bool $deleteTalk
143     * @return StatusValue<int|false> Same as DeletePage::deleteIfAllowed, but if the status is good, then:
144     *  - For immediate deletions, the value is the ID of the deletion
145     *  - For scheduled deletions, the value is false
146     *   If $deleteTalk is set, no information about the deletion of the talk page is included in the returned Status.
147     */
148    private function delete( WikiPage $page, &$reason, array $tags, bool $deleteTalk ): StatusValue {
149        $title = $page->getTitle();
150
151        // Auto-generate a summary, if necessary
152        if ( $reason === null ) {
153            $reason = $page->getAutoDeleteReason();
154            if ( $reason === false ) {
155                // Should be reachable only if the page has no revisions
156                return Status::newFatal( 'cannotdelete', $title->getPrefixedText() ); // @codeCoverageIgnore
157            }
158        }
159
160        $deletePage = $this->deletePageFactory->newDeletePage( $page, $this->getAuthority() );
161        if ( $deleteTalk ) {
162            $checkStatus = $deletePage->canProbablyDeleteAssociatedTalk();
163            if ( !$checkStatus->isGood() ) {
164                foreach ( $checkStatus->getMessages() as $msg ) {
165                    $this->addWarning( $msg );
166                }
167            } else {
168                $deletePage->setDeleteAssociatedTalk( true );
169            }
170        }
171        $deletionStatus = $deletePage->setTags( $tags )->deleteIfAllowed( $reason );
172        if ( $deletionStatus->isGood() ) {
173            // @phan-suppress-next-line PhanTypeMismatchProperty Changing the type of the status parameter
174            $deletionStatus->value = $deletePage->deletionsWereScheduled()[DeletePage::PAGE_BASE]
175                ? false
176                : $deletePage->getSuccessfulDeletionsIDs()[DeletePage::PAGE_BASE];
177        }
178        return $deletionStatus;
179    }
180
181    /**
182     * @param File $file
183     * @return bool
184     */
185    protected static function canDeleteFile( File $file ) {
186        return $file->exists() && $file->isLocal() && !$file->getRedirected();
187    }
188
189    /**
190     * @param WikiPage $page Object to work on
191     * @param string $oldimage Archive name
192     * @param string|null &$reason Reason for the deletion. Autogenerated if null.
193     * @param bool $suppress Whether to mark all deleted versions as restricted
194     * @param string[] $tags Tags to tag the deletion with
195     * @param bool $deleteTalk
196     * @return StatusValue
197     */
198    private function deleteFile(
199        WikiPage $page,
200        $oldimage,
201        &$reason,
202        bool $suppress,
203        array $tags,
204        bool $deleteTalk
205    ) {
206        $title = $page->getTitle();
207
208        // @phan-suppress-next-line PhanUndeclaredMethod There's no right typehint for it
209        $file = $page->getFile();
210        if ( !self::canDeleteFile( $file ) ) {
211            return $this->delete( $page, $reason, $tags, $deleteTalk );
212        }
213
214        // Check that the user is allowed to carry out the deletion
215        $this->checkTitleUserPermissions( $page->getTitle(), 'delete' );
216        if ( $tags ) {
217            // If change tagging was requested, check that the user is allowed to tag,
218            // and the tags are valid
219            $tagStatus = ChangeTags::canAddTagsAccompanyingChange( $tags, $this->getAuthority() );
220            if ( !$tagStatus->isOK() ) {
221                $this->dieStatus( $tagStatus );
222            }
223        }
224
225        if ( $oldimage ) {
226            if ( !FileDeleteForm::isValidOldSpec( $oldimage ) ) {
227                return Status::newFatal( 'invalidoldimage' );
228            }
229            $oldfile = $this->repoGroup->getLocalRepo()->newFromArchiveName( $title, $oldimage );
230            if ( !$oldfile->exists() || !$oldfile->isLocal() || $oldfile->getRedirected() ) {
231                return Status::newFatal( 'nodeleteablefile' );
232            }
233        }
234
235        return FileDeleteForm::doDelete(
236            $title,
237            $file,
238            $oldimage,
239            // Log and RC don't like null reasons
240            $reason ?? '',
241            $suppress,
242            $this->getUser(),
243            $tags,
244            $deleteTalk
245        );
246    }
247
248    /** @inheritDoc */
249    public function mustBePosted() {
250        return true;
251    }
252
253    /** @inheritDoc */
254    public function isWriteMode() {
255        return true;
256    }
257
258    /** @inheritDoc */
259    public function getAllowedParams() {
260        $params = [
261            'title' => null,
262            'pageid' => [
263                ParamValidator::PARAM_TYPE => 'integer'
264            ],
265            'reason' => null,
266            'tags' => [
267                ParamValidator::PARAM_TYPE => 'tags',
268                ParamValidator::PARAM_ISMULTI => true,
269            ],
270            'deletetalk' => false,
271            'watch' => [
272                ParamValidator::PARAM_DEFAULT => false,
273                ParamValidator::PARAM_DEPRECATED => true,
274            ],
275        ];
276
277        // Params appear in the docs in the order they are defined,
278        // which is why this is here and not at the bottom.
279        $params += $this->getWatchlistParams();
280
281        return $params + [
282            'unwatch' => [
283                ParamValidator::PARAM_DEFAULT => false,
284                ParamValidator::PARAM_DEPRECATED => true,
285            ],
286            'oldimage' => null,
287        ];
288    }
289
290    /** @inheritDoc */
291    public function needsToken() {
292        return 'csrf';
293    }
294
295    /** @inheritDoc */
296    protected function getExamplesMessages() {
297        $title = Title::newMainPage()->getPrefixedText();
298        $mp = rawurlencode( $title );
299
300        return [
301            "action=delete&title={$mp}&token=123ABC"
302                => 'apihelp-delete-example-simple',
303            "action=delete&title={$mp}&token=123ABC&reason=Preparing%20for%20move"
304                => 'apihelp-delete-example-reason',
305        ];
306    }
307
308    /** @inheritDoc */
309    public function getHelpUrls() {
310        return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Delete';
311    }
312}
313
314/** @deprecated class alias since 1.43 */
315class_alias( ApiDelete::class, 'ApiDelete' );