Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.85% covered (warning)
76.85%
83 / 108
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikiPageConfigWriter
76.85% covered (warning)
76.85%
83 / 108
66.67% covered (warning)
66.67%
6 / 9
46.51
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getCurrentWikiConfig
62.50% covered (warning)
62.50%
10 / 16
0.00% covered (danger)
0.00%
0 / 1
4.84
 loadConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 pruneConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setVariable
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 variableExists
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 setVariables
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 save
91.89% covered (success)
91.89%
34 / 37
0.00% covered (danger)
0.00%
0 / 1
11.06
 runEditFilterMergedContentHook
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace GrowthExperiments\Config;
4
5use Content;
6use DerivativeContext;
7use FormatJson;
8use GrowthExperiments\Config\Validation\IConfigValidator;
9use IDBAccessObject;
10use InvalidArgumentException;
11use JsonContent;
12use MediaWiki\CommentStore\CommentStoreComment;
13use MediaWiki\HookContainer\HookContainer;
14use MediaWiki\HookContainer\HookRunner;
15use MediaWiki\Linker\LinkTarget;
16use MediaWiki\Page\WikiPageFactory;
17use MediaWiki\Revision\SlotRecord;
18use MediaWiki\Status\Status;
19use MediaWiki\Title\Title;
20use MediaWiki\Title\TitleFactory;
21use MediaWiki\User\User;
22use MediaWiki\User\UserFactory;
23use MediaWiki\User\UserIdentity;
24use Psr\Log\LoggerInterface;
25use RecentChange;
26use RequestContext;
27
28class WikiPageConfigWriter {
29
30    private LinkTarget $configPage;
31    private UserIdentity $performer;
32    private IConfigValidator $configValidator;
33    private WikiPageConfigLoader $wikiPageConfigLoader;
34    private WikiPageFactory $wikiPageFactory;
35    private TitleFactory $titleFactory;
36    private UserFactory $userFactory;
37    private HookContainer $hookContainer;
38    private LoggerInterface $logger;
39    private ?array $wikiConfig = null;
40
41    /**
42     * @param IConfigValidator $configValidator
43     * @param WikiPageConfigLoader $wikiPageConfigLoader
44     * @param WikiPageFactory $wikiPageFactory
45     * @param TitleFactory $titleFactory
46     * @param UserFactory $userFactory
47     * @param HookContainer $hookContainer
48     * @param LoggerInterface $logger
49     * @param LinkTarget $configPage
50     * @param UserIdentity $performer
51     */
52    public function __construct(
53        IConfigValidator $configValidator,
54        WikiPageConfigLoader $wikiPageConfigLoader,
55        WikiPageFactory $wikiPageFactory,
56        TitleFactory $titleFactory,
57        UserFactory $userFactory,
58        HookContainer $hookContainer,
59        LoggerInterface $logger,
60        LinkTarget $configPage,
61        UserIdentity $performer
62    ) {
63        $this->configValidator = $configValidator;
64        $this->wikiPageConfigLoader = $wikiPageConfigLoader;
65        $this->wikiPageFactory = $wikiPageFactory;
66        $this->titleFactory = $titleFactory;
67        $this->userFactory = $userFactory;
68        $this->hookContainer = $hookContainer;
69        $this->logger = $logger;
70
71        $this->configPage = $configPage;
72        $this->performer = $performer;
73    }
74
75    /**
76     * Return current wiki config, loaded via WikiPageConfigLoader
77     *
78     * @return array
79     */
80    private function getCurrentWikiConfig(): array {
81        if ( $this->titleFactory->newFromLinkTarget( $this->configPage )->exists() ) {
82            $config = $this->wikiPageConfigLoader->load(
83                $this->configPage,
84                IDBAccessObject::READ_LATEST
85            );
86            if ( !is_array( $config ) ) {
87                if ( $config instanceof Status ) {
88                    // In case config loader returned a status object, log details that could
89                    // be useful for debugging.
90                    $this->logger->error(
91                        __METHOD__ . ' failed to load config from ' . $this->configPage . ', Status object returned',
92                        [
93                            'errorArray' => $config->getErrors()
94                        ]
95                    );
96                }
97                throw new InvalidArgumentException( __METHOD__ . ' failed to load config from ' . $this->configPage );
98            }
99            return $config;
100        } else {
101            return [];
102        }
103    }
104
105    /**
106     * Load wiki-config via WikiPageConfigLoader, if some exists
107     */
108    private function loadConfig(): void {
109        $this->wikiConfig = $this->getCurrentWikiConfig();
110    }
111
112    /**
113     * Unset all config variables
114     *
115     * Useful for migration purposes, or for other places where we want to
116     * start with an empty config.
117     */
118    public function pruneConfig(): void {
119        $this->wikiConfig = [];
120    }
121
122    /**
123     * @param string|array $variable Variable name, or a list where the first item is the
124     *   variable name and subsequent items are array keys, e.g. [ 'foo', 'bar', 'baz' ]
125     *   means changing $foo['bar']['baz'] (where $foo stands for the 'foo' variable).
126     * @param mixed $value
127     * @throws InvalidArgumentException when $variable is an array but the variable it refers to isn't.
128     */
129    public function setVariable( $variable, $value ): void {
130        if ( $this->wikiConfig === null ) {
131            $this->loadConfig();
132        }
133
134        if ( is_string( $variable ) ) {
135            $baseVariable = $variable;
136            $fullValue = $value;
137        } else {
138            $baseVariable = array_shift( $variable );
139            $fullValue = $this->wikiConfig[$baseVariable] ?? [];
140            $field = &$fullValue;
141            foreach ( $variable as $key ) {
142                if ( !is_array( $field ) ) {
143                    throw new InvalidArgumentException( 'Trying to set a sub-field of a non-array' );
144                }
145                $field = &$field[$key];
146            }
147            $field = $value;
148        }
149
150        $this->configValidator->validateVariable( $baseVariable, $fullValue );
151        $this->wikiConfig[$baseVariable] = $fullValue;
152    }
153
154    /**
155     * Check if a given variable or a subfield exists.
156     * @param string|array $variable Variable name, or a list where the first item is the
157     *   variable name and subsequent items are array keys, e.g. [ 'foo', 'bar', 'baz' ]
158     *   means checking $foo['bar']['baz'] (where $foo stands for the 'foo' variable).
159     * @return bool Whether the variable exists. The semantics are like array_key_exists().
160     * @throws InvalidArgumentException when $variable is an array but the variable it refers to isn't.
161     */
162    public function variableExists( $variable ): bool {
163        if ( $this->wikiConfig === null ) {
164            $this->loadConfig();
165        }
166        $variablePath = (array)$variable;
167        $config = $this->wikiConfig;
168        foreach ( $variablePath as $pathSegment ) {
169            if ( !is_array( $config ) ) {
170                throw new InvalidArgumentException( 'Trying to check a sub-field of a non-array' );
171            }
172            if ( !array_key_exists( $pathSegment, $config ) ) {
173                return false;
174            }
175            $config = $config[$pathSegment] ?? [];
176        }
177        return true;
178    }
179
180    /**
181     * @param array $variables
182     */
183    public function setVariables( array $variables ): void {
184        foreach ( $variables as $variable => $value ) {
185            $this->setVariable( $variable, $value );
186        }
187    }
188
189    /**
190     * @param string $summary
191     * @param bool $minor
192     * @param array|string $tags Tag(s) to apply (defaults to none)
193     * @param bool $bypassWarnings Should warnings/non-fatals stop the operation? Defaults to
194     * true.
195     * @return Status
196     */
197    public function save(
198        string $summary = '',
199        bool $minor = false,
200        $tags = [],
201        bool $bypassWarnings = true
202    ): Status {
203        // Load config if not done already, to support null-edits
204        if ( $this->wikiConfig === null ) {
205            $this->loadConfig();
206        }
207
208        // Sort config alphabetically
209        ksort( $this->wikiConfig, SORT_STRING );
210
211        $status = Status::newGood();
212        $status->merge( $this->configValidator->validate( $this->wikiConfig ) );
213
214        if (
215            !$status->isOK() ||
216            ( !$bypassWarnings && !$status->isGood() )
217        ) {
218            $status->setOK( false );
219            return $status;
220        }
221
222        // Save only if config was changed, so editing interface
223        // doesn't need to make sure config was indeed changed.
224        if ( $this->wikiConfig !== $this->getCurrentWikiConfig() ) {
225            $page = $this->wikiPageFactory->newFromLinkTarget( $this->configPage );
226            $content = new JsonContent( FormatJson::encode( $this->wikiConfig ) );
227            $performerUser = $this->userFactory->newFromUserIdentity( $this->performer );
228
229            // Give AbuseFilter et al. a chance to block the edit (T346235)
230            $status->merge( $this->runEditFilterMergedContentHook(
231                $performerUser,
232                $page->getTitle(),
233                $content,
234                $summary,
235                $minor
236            ) );
237
238            if ( !$status->isOK() ) {
239                return $status;
240            }
241
242            $updater = $page->newPageUpdater( $this->performer );
243            if ( is_string( $tags ) ) {
244                $updater->addTag( $tags );
245            } elseif ( is_array( $tags ) ) {
246                $updater->addTags( $tags );
247            }
248            $updater->setContent( SlotRecord::MAIN, $content );
249
250            if ( $performerUser->isAllowed( 'autopatrol' ) ) {
251                $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
252            }
253
254            $updater->saveRevision(
255                CommentStoreComment::newUnsavedComment( $summary ),
256                $minor ? EDIT_MINOR : 0
257            );
258            $status->merge( $updater->getStatus() );
259        }
260
261        // Invalidate config cache regardless of whether any variable was changed
262        // to let users to invalidate cache when they wish so (similar to action=purge
263        // or null edit concepts)
264        $this->wikiPageConfigLoader->invalidate( $this->configPage );
265
266        return $status;
267    }
268
269    /**
270     * Run the EditFilterMergedContentHook
271     *
272     * @param User $performerUser
273     * @param Title $title
274     * @param Content $content
275     * @param string $summary
276     * @param bool $minor
277     * @return Status
278     */
279    private function runEditFilterMergedContentHook(
280        User $performerUser,
281        Title $title,
282        Content $content,
283        string $summary,
284        bool $minor
285    ): Status {
286        // Ensure context has right values for title and performer, which are available to the
287        // config writer. Use the global context for the rest.
288        $derivativeContext = new DerivativeContext( RequestContext::getMain() );
289        $derivativeContext->setUser( $performerUser );
290        $derivativeContext->setTitle( $title );
291
292        $status = new Status();
293        $hookRunner = new HookRunner( $this->hookContainer );
294        if ( !$hookRunner->onEditFilterMergedContent(
295            $derivativeContext,
296            $content,
297            $status,
298            $summary,
299            $performerUser,
300            $minor
301        ) ) {
302            if ( $status->isGood() ) {
303                $status->fatal( 'hookaborted' );
304            }
305        }
306        return $status;
307    }
308}