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