Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 91
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
BlockedDomainStorage
0.00% covered (danger)
0.00%
0 / 91
0.00% covered (danger)
0.00%
0 / 11
992
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 makeCacheKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 loadConfig
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 loadComputed
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 validateDomain
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 fetchConfig
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 addDomain
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 removeDomain
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 fetchLatestConfig
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 saveContent
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getBlockedDomainPage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20namespace MediaWiki\Extension\AbuseFilter;
21
22use ApiRawMessage;
23use BagOStuff;
24use DBAccessObjectUtils;
25use FormatJson;
26use IDBAccessObject;
27use JsonContent;
28use MediaWiki\CommentStore\CommentStoreComment;
29use MediaWiki\Page\WikiPageFactory;
30use MediaWiki\Revision\RevisionLookup;
31use MediaWiki\Revision\RevisionRecord;
32use MediaWiki\Revision\SlotRecord;
33use MediaWiki\Title\TitleValue;
34use MediaWiki\User\UserFactory;
35use MediaWiki\Utils\UrlUtils;
36use Message;
37use RecentChange;
38use StatusValue;
39
40/**
41 * Hold and update information about blocked external domains
42 *
43 * @ingroup SpecialPage
44 */
45class BlockedDomainStorage implements IDBAccessObject {
46    public const SERVICE_NAME = 'AbuseFilterBlockedDomainStorage';
47    public const TARGET_PAGE = 'BlockedExternalDomains.json';
48
49    private RevisionLookup $revisionLookup;
50    private BagOStuff $cache;
51    private UserFactory $userFactory;
52    private WikiPageFactory $wikiPageFactory;
53    private UrlUtils $urlUtils;
54
55    /**
56     * @param BagOStuff $cache Local-server caching
57     * @param RevisionLookup $revisionLookup
58     * @param UserFactory $userFactory
59     * @param WikiPageFactory $wikiPageFactory
60     * @param UrlUtils $urlUtils
61     */
62    public function __construct(
63        BagOStuff $cache,
64        RevisionLookup $revisionLookup,
65        UserFactory $userFactory,
66        WikiPageFactory $wikiPageFactory,
67        UrlUtils $urlUtils
68    ) {
69        $this->cache = $cache;
70        $this->revisionLookup = $revisionLookup;
71        $this->userFactory = $userFactory;
72        $this->wikiPageFactory = $wikiPageFactory;
73        $this->urlUtils = $urlUtils;
74    }
75
76    /**
77     * @return string
78     */
79    private function makeCacheKey() {
80        return $this->cache->makeKey( 'abusefilter-blocked-domains' );
81    }
82
83    /**
84     * Load the configuration page, with optional local-server caching.
85     *
86     * @param int $flags bit field, see IDBAccessObject::READ_XXX
87     * @return StatusValue The content of the configuration page (as JSON
88     *   data in PHP-native format), or a StatusValue on error.
89     */
90    public function loadConfig( int $flags = 0 ): StatusValue {
91        if ( DBAccessObjectUtils::hasFlags( $flags, IDBAccessObject::READ_LATEST ) ) {
92            return $this->fetchConfig( $flags );
93        }
94
95        // Load configuration from APCU
96        return $this->cache->getWithSetCallback(
97            $this->makeCacheKey(),
98            BagOStuff::TTL_MINUTE * 5,
99            function ( &$ttl ) use ( $flags ) {
100                $result = $this->fetchConfig( $flags );
101                if ( !$result->isGood() ) {
102                    // error should not be cached
103                    $ttl = BagOStuff::TTL_UNCACHEABLE;
104                }
105                return $result;
106            }
107        );
108    }
109
110    /**
111     * Load the computed domain block list
112     *
113     * @return array<string,true> Flipped for performance reasons
114     */
115    public function loadComputed(): array {
116        return $this->cache->getWithSetCallback(
117            $this->cache->makeKey( 'abusefilter-blocked-domains-computed' ),
118            BagOStuff::TTL_MINUTE * 5,
119            function () {
120                $status = $this->loadConfig();
121                if ( !$status->isGood() ) {
122                    return [];
123                }
124                $computedDomains = [];
125                foreach ( $status->getValue() as $domain ) {
126                    if ( !( $domain['domain'] ?? null ) ) {
127                        continue;
128                    }
129                    $validatedDomain = $this->validateDomain( $domain['domain'] );
130                    if ( $validatedDomain ) {
131                        // It should be a map, benchmark at https://phabricator.wikimedia.org/P48956
132                        $computedDomains[$validatedDomain] = true;
133                    }
134                }
135                return $computedDomains;
136            }
137        );
138    }
139
140    /**
141     * Validate an input domain
142     *
143     * @param string|null $domain Domain such as foo.wikipedia.org
144     * @return string|false Parsed domain, or false otherwise
145     */
146    public function validateDomain( $domain ) {
147        if ( !$domain ) {
148            return false;
149        }
150
151        $domain = trim( $domain );
152        if ( !str_contains( $domain, '//' ) ) {
153            $domain = 'https://' . $domain;
154        }
155
156        $parsedUrl = $this->urlUtils->parse( $domain );
157        // Parse url returns a valid URL for "foo"
158        if ( !$parsedUrl || !str_contains( $parsedUrl['host'], '.' ) ) {
159            return false;
160        }
161        return $parsedUrl['host'];
162    }
163
164    /**
165     * Fetch the contents of the configuration page, without caching.
166     *
167     * Result is not validated with a config validator.
168     *
169     * @param int $flags bit field, see IDBAccessObject::READ_XXX; do NOT pass READ_UNCACHED
170     * @return StatusValue Status object, with the configuration (as JSON data) on success.
171     */
172    private function fetchConfig( int $flags ): StatusValue {
173        $revision = $this->revisionLookup->getRevisionByTitle( $this->getBlockedDomainPage(), 0, $flags );
174        if ( !$revision ) {
175            // The configuration page does not exist. Pretend it does not configure anything
176            // specific (failure mode and empty-page behavior is equal).
177            return StatusValue::newGood( [] );
178        }
179        $content = $revision->getContent( SlotRecord::MAIN );
180        if ( !$content instanceof JsonContent ) {
181            return StatusValue::newFatal( new ApiRawMessage(
182                'The configuration title has no content or is not JSON content.',
183                'newcomer-tasks-configuration-loader-content-error' ) );
184        }
185
186        return FormatJson::parse( $content->getText(), FormatJson::FORCE_ASSOC );
187    }
188
189    /**
190     * This doesn't do validation.
191     *
192     * @param string $domain domain to be blocked
193     * @param string $notes User provided notes
194     * @param \MediaWiki\Permissions\Authority|\MediaWiki\User\UserIdentity $user Performer
195     * @return RevisionRecord|null Null on failure
196     */
197    public function addDomain( string $domain, string $notes, $user ): ?RevisionRecord {
198        $content = $this->fetchLatestConfig();
199        if ( $content === null ) {
200            return null;
201        }
202        $content[] = [ 'domain' => $domain, 'notes' => $notes, 'addedBy' => $user->getName() ];
203        $comment = Message::newFromSpecifier( 'abusefilter-blocked-domains-domain-added-comment' )
204            ->params( $domain, $notes )
205            ->plain();
206        return $this->saveContent( $content, $user, $comment );
207    }
208
209    /**
210     * This doesn't do validation
211     *
212     * @param string $domain domain to be removed from the blocked list
213     * @param string $notes User provided notes
214     * @param \MediaWiki\Permissions\Authority|\MediaWiki\User\UserIdentity $user Performer
215     * @return RevisionRecord|null Null on failure
216     */
217    public function removeDomain( string $domain, string $notes, $user ): ?RevisionRecord {
218        $content = $this->fetchLatestConfig();
219        if ( $content === null ) {
220            return null;
221        }
222        foreach ( $content as $key => $value ) {
223            if ( ( $value['domain'] ?? '' ) == $domain ) {
224                unset( $content[$key] );
225            }
226        }
227        $comment = Message::newFromSpecifier( 'abusefilter-blocked-domains-domain-removed-comment' )
228            ->params( $domain, $notes )
229            ->plain();
230        return $this->saveContent( array_values( $content ), $user, $comment );
231    }
232
233    /**
234     * @return array[]|null Empty array when the page doesn't exist, null on failure
235     */
236    private function fetchLatestConfig(): ?array {
237        $configPage = $this->getBlockedDomainPage();
238        $revision = $this->revisionLookup->getRevisionByTitle( $configPage, 0, IDBAccessObject::READ_LATEST );
239        if ( !$revision ) {
240            return [];
241        }
242
243        $revContent = $revision->getContent( SlotRecord::MAIN );
244        if ( $revContent instanceof JsonContent ) {
245            $status = FormatJson::parse( $revContent->getText(), FormatJson::FORCE_ASSOC );
246            if ( $status->isOK() ) {
247                return $status->getValue();
248            }
249        }
250
251        return null;
252    }
253
254    /**
255     * Save the provided content into the page
256     *
257     * @param array[] $content To be turned into JSON
258     * @param \MediaWiki\Permissions\Authority|\MediaWiki\User\UserIdentity $user Performer
259     * @param string $comment Save comment
260     * @return RevisionRecord|null
261     */
262    private function saveContent( array $content, $user, $comment ): ?RevisionRecord {
263        $configPage = $this->getBlockedDomainPage();
264        $page = $this->wikiPageFactory->newFromLinkTarget( $configPage );
265        $updater = $page->newPageUpdater( $user );
266        $updater->setContent( SlotRecord::MAIN, new JsonContent( FormatJson::encode( $content ) ) );
267
268        if ( $this->userFactory->newFromUserIdentity( $user )->isAllowed( 'autopatrol' ) ) {
269            $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
270        }
271
272        return $updater->saveRevision(
273            CommentStoreComment::newUnsavedComment( $comment )
274        );
275    }
276
277    /**
278     * @return TitleValue TitleValue of the config json page
279     */
280    private function getBlockedDomainPage() {
281        return new TitleValue( NS_MEDIAWIKI, self::TARGET_PAGE );
282    }
283}