Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
76.85% |
83 / 108 |
|
66.67% |
6 / 9 |
CRAP | |
0.00% |
0 / 1 |
WikiPageConfigWriter | |
76.85% |
83 / 108 |
|
66.67% |
6 / 9 |
46.51 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
getCurrentWikiConfig | |
62.50% |
10 / 16 |
|
0.00% |
0 / 1 |
4.84 | |||
loadConfig | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
pruneConfig | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setVariable | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
5 | |||
variableExists | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
setVariables | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
save | |
91.89% |
34 / 37 |
|
0.00% |
0 / 1 |
11.06 | |||
runEditFilterMergedContentHook | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
12 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\Config; |
4 | |
5 | use Content; |
6 | use DerivativeContext; |
7 | use FormatJson; |
8 | use GrowthExperiments\Config\Validation\IConfigValidator; |
9 | use IDBAccessObject; |
10 | use InvalidArgumentException; |
11 | use JsonContent; |
12 | use MediaWiki\CommentStore\CommentStoreComment; |
13 | use MediaWiki\HookContainer\HookContainer; |
14 | use MediaWiki\HookContainer\HookRunner; |
15 | use MediaWiki\Linker\LinkTarget; |
16 | use MediaWiki\Page\WikiPageFactory; |
17 | use MediaWiki\Revision\SlotRecord; |
18 | use MediaWiki\Status\Status; |
19 | use MediaWiki\Title\Title; |
20 | use MediaWiki\Title\TitleFactory; |
21 | use MediaWiki\User\User; |
22 | use MediaWiki\User\UserFactory; |
23 | use MediaWiki\User\UserIdentity; |
24 | use Psr\Log\LoggerInterface; |
25 | use RecentChange; |
26 | use RequestContext; |
27 | |
28 | class 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 | } |