Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.19% covered (success)
92.19%
59 / 64
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikiPageConfigLoader
92.19% covered (success)
92.19%
59 / 64
62.50% covered (warning)
62.50%
5 / 8
18.15
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 makeCacheKey
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 invalidate
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 load
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 loadFromWanCache
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 removeCustomFlags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadUncached
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 fetchConfig
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
6.01
1<?php
2
3namespace AutoModerator\Config;
4
5use ApiRawMessage;
6use AutoModerator\Config\Validation\ConfigValidatorFactory;
7use AutoModerator\Util;
8use DBAccessObjectUtils;
9use HashBagOStuff;
10use IDBAccessObject;
11use MediaWiki\Content\JsonContent;
12use MediaWiki\Http\HttpRequestFactory;
13use MediaWiki\Json\FormatJson;
14use MediaWiki\Linker\LinkTarget;
15use MediaWiki\Revision\RevisionLookup;
16use MediaWiki\Revision\RevisionRecord;
17use MediaWiki\Revision\SlotRecord;
18use MediaWiki\Title\TitleFactory;
19use MediaWiki\Utils\UrlUtils;
20use StatusValue;
21use WANObjectCache;
22use Wikimedia\LightweightObjectStore\ExpirationAwareness;
23
24/**
25 * This class allows callers to fetch various variables
26 * from JSON pages stored on-wiki (the pages need to have JSON
27 * as their content model).
28 *
29 * The MediaWiki pages need to be formatted like this:
30 * {
31 *         "ConfigVariable": "value",
32 *         "OtherConfigVariable": "value"
33 * }
34 */
35class WikiPageConfigLoader implements ICustomReadConstants {
36
37    private ConfigValidatorFactory $configValidatorFactory;
38    private HttpRequestFactory $requestFactory;
39    private RevisionLookup $revisionLookup;
40    private TitleFactory $titleFactory;
41    private WANObjectCache $cache;
42    private HashBagOStuff $inProcessCache;
43    private UrlUtils $urlUtils;
44    /**
45     * @var bool Hack to disable DB access in non-database tests. The proper replacement to this would be a
46     * NullConfigLoader or similar class, and the ServiceWiring code would determine which one to use.
47     */
48    private bool $isTestWithStorageDisabled;
49
50    /**
51     * @param WANObjectCache $cache
52     * @param ConfigValidatorFactory $configValidatorFactory
53     * @param HttpRequestFactory $requestFactory
54     * @param RevisionLookup $revisionLookup
55     * @param TitleFactory $titleFactory
56     * @param UrlUtils $urlUtils
57     * @param bool $isTestWithStorageDisabled
58     */
59    public function __construct(
60        WANObjectCache $cache,
61        ConfigValidatorFactory $configValidatorFactory,
62        HttpRequestFactory $requestFactory,
63        RevisionLookup $revisionLookup,
64        TitleFactory $titleFactory,
65        UrlUtils $urlUtils,
66        bool $isTestWithStorageDisabled
67    ) {
68        $this->cache = $cache;
69        $this->inProcessCache = new HashBagOStuff();
70        $this->configValidatorFactory = $configValidatorFactory;
71        $this->requestFactory = $requestFactory;
72        $this->revisionLookup = $revisionLookup;
73        $this->titleFactory = $titleFactory;
74        $this->urlUtils = $urlUtils;
75        $this->isTestWithStorageDisabled = $isTestWithStorageDisabled;
76    }
77
78    /**
79     * @param LinkTarget $configPage
80     * @return string
81     */
82    private function makeCacheKey( LinkTarget $configPage ) {
83        return $this->cache->makeKey( 'AutoModerator',
84            'config', $configPage->getNamespace(), $configPage->getDBkey() );
85    }
86
87    /**
88     * @param LinkTarget $configPage
89     */
90    public function invalidate( LinkTarget $configPage ) {
91        $cacheKey = $this->makeCacheKey( $configPage );
92        $this->cache->delete( $cacheKey );
93        $this->inProcessCache->delete( $cacheKey );
94    }
95
96    /**
97     * Load the configured page, with caching.
98     * @param LinkTarget $configPage
99     * @param int $flags bit field, see IDBAccessObject::READ_XXX
100     * @return array|StatusValue The content of the configuration page (as JSON
101     *   data in PHP-native format), or a StatusValue on error.
102     */
103    public function load( LinkTarget $configPage, int $flags = 0 ) {
104        if (
105            DBAccessObjectUtils::hasFlags( $flags, IDBAccessObject::READ_LATEST ) ||
106            // This is a custom flag, but bitfield logic should work regardless.
107            DBAccessObjectUtils::hasFlags( $flags, self::READ_UNCACHED )
108        ) {
109            // User does not want to use cached data, invalidate the cache.
110            $this->invalidate( $configPage );
111        }
112
113        // WANObjectCache has an in-process cache (pcTTL), but it is not subject
114        // to invalidation, which breaks WikiPageConfigLoaderTest.
115        return $this->inProcessCache->getWithSetCallback(
116            $this->makeCacheKey( $configPage ),
117            ExpirationAwareness::TTL_INDEFINITE,
118            function () use ( $configPage, $flags ) {
119                return $this->loadFromWanCache( $configPage, $flags );
120            }
121        );
122    }
123
124    /**
125     * Load configuration from the WAN cache
126     *
127     * @param LinkTarget $configPage
128     * @param int $flags bit field, see IDBAccessObject::READ_XXX
129     * @return array|StatusValue The content of the configuration page (as JSON
130     *   data in PHP-native format), or a StatusValue on error.
131     */
132    private function loadFromWanCache( LinkTarget $configPage, int $flags = 0 ) {
133        return $this->cache->getWithSetCallback(
134            $this->makeCacheKey( $configPage ),
135            // Cache config for a day; cache is invalidated by ConfigHooks::onPageSaveComplete
136            // and WikiPageConfigWriter::save when config files are changed.,
137            ExpirationAwareness::TTL_DAY,
138            function ( $oldValue, &$ttl ) use ( $configPage, $flags ) {
139                $result = $this->loadUncached( $configPage, $flags );
140                if ( $result instanceof StatusValue ) {
141                    // error should not be cached
142                    $ttl = ExpirationAwareness::TTL_UNCACHEABLE;
143                }
144                return $result;
145            }
146        );
147    }
148
149    /**
150     *
151     * @param int $flags Bitfield consisting of READ_* constants
152     * @return int Bitfield consisting only of standard IDBAccessObject READ_* constants.
153     */
154    private function removeCustomFlags( int $flags ): int {
155        return $flags & ~self::READ_UNCACHED;
156    }
157
158    /**
159     * Load the configuration page, bypassing caching.
160     *
161     * Caller is responsible for caching the result if desired.
162     *
163     * @param LinkTarget $configPage
164     * @param int $flags
165     * @return array|StatusValue
166     */
167    private function loadUncached( LinkTarget $configPage, int $flags = 0 ) {
168        $result = false;
169        $status = $this->fetchConfig( $configPage, $this->removeCustomFlags( $flags ) );
170        if ( $status->isOK() ) {
171            $result = $status->getValue();
172            $status->merge(
173                $this->configValidatorFactory
174                    ->newConfigValidator( $configPage )
175                    ->validate( $result )
176            );
177        }
178        if ( !$status->isOK() ) {
179            $result = $status;
180        }
181
182        return $result;
183    }
184
185    /**
186     * Fetch the contents of the configuration page, without caching.
187     *
188     * Result is not validated with a config validator.
189     *
190     * @param LinkTarget $configPage
191     * @param int $flags bit field, see IDBAccessObject::READ_XXX; do NOT pass READ_UNCACHED
192     * @return StatusValue Status object, with the configuration (as JSON data) on success.
193     */
194    private function fetchConfig( LinkTarget $configPage, int $flags ) {
195        if ( $configPage->isExternal() ) {
196            $url = Util::getRawUrl( $configPage, $this->titleFactory, $this->urlUtils );
197            return Util::getJsonUrl( $this->requestFactory, $url );
198        }
199
200        $revision = $this->isTestWithStorageDisabled
201            ? null
202            : $this->revisionLookup->getRevisionByTitle( $configPage, 0, $flags );
203        if ( !$revision ) {
204            // The configuration page does not exist. Pretend it does not configure anything
205            // specific (failure mode and empty-page behavior is equal, see T325236).
206            return StatusValue::newGood( $this->configValidatorFactory
207                ->newConfigValidator( $configPage )
208                ->getDefaultContent()
209            );
210        }
211        $content = $revision->getContent( SlotRecord::MAIN, RevisionRecord::FOR_PUBLIC );
212        if ( !$content || !$content instanceof JsonContent ) {
213            return StatusValue::newFatal( new ApiRawMessage(
214                'The configuration title has no content or is not JSON content.',
215                'automoderator-configuration-loader-content-error'
216            ) );
217        }
218
219        return FormatJson::parse( $content->getText(), FormatJson::FORCE_ASSOC );
220    }
221}