Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 144 |
|
0.00% |
0 / 8 |
CRAP | |
0.00% |
0 / 1 |
XMLStore | |
0.00% |
0 / 144 |
|
0.00% |
0 / 8 |
2862 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
readFile | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
doTopLevel | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
72 | |||
doElection | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
156 | |||
readEntity | |
0.00% |
0 / 58 |
|
0.00% |
0 / 1 |
272 | |||
addParentIds | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
20 | |||
readStringElement | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
72 | |||
callbackValidVotes | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\SecurePoll\Store; |
4 | |
5 | use MediaWiki\Status\Status; |
6 | use XMLReader; |
7 | |
8 | /** |
9 | * Storage class for an XML file store. Election configuration data is cached, |
10 | * and vote data can be loaded into a tallier on demand. |
11 | */ |
12 | class XMLStore extends MemoryStore { |
13 | /** @var XMLReader|null */ |
14 | public $xmlReader; |
15 | /** @var string */ |
16 | public $fileName; |
17 | /** @var callable|null */ |
18 | public $voteCallback; |
19 | /** @var int|null */ |
20 | public $voteElectionId; |
21 | /** @var Status|null */ |
22 | public $voteCallbackStatus; |
23 | |
24 | /** @var string[][] Valid entity info keys by entity type. */ |
25 | private static $entityInfoKeys = [ |
26 | 'election' => [ |
27 | 'id', |
28 | 'title', |
29 | 'ballot', |
30 | 'tally', |
31 | 'primaryLang', |
32 | 'startDate', |
33 | 'endDate', |
34 | 'auth' |
35 | ], |
36 | 'question' => [ |
37 | 'id', |
38 | 'election' |
39 | ], |
40 | 'option' => [ |
41 | 'id', |
42 | 'election' |
43 | ], |
44 | ]; |
45 | |
46 | /** @var string[][] The type of each entity child and its corresponding (plural) info element */ |
47 | private static $childTypes = [ |
48 | 'election' => [ 'question' => 'questions' ], |
49 | 'question' => [ 'option' => 'options' ], |
50 | 'option' => [] |
51 | ]; |
52 | |
53 | /** @var string[] All entity types */ |
54 | private static $entityTypes = [ |
55 | 'election', |
56 | 'question', |
57 | 'option' |
58 | ]; |
59 | |
60 | /** |
61 | * Constructor. Note that readFile() must be called before any information |
62 | * can be accessed. Context::newFromXmlFile() is a shortcut |
63 | * method for this. |
64 | * @param string $fileName |
65 | */ |
66 | public function __construct( $fileName ) { |
67 | $this->fileName = $fileName; |
68 | } |
69 | |
70 | /** |
71 | * Read the file and return boolean success. |
72 | * @return bool |
73 | */ |
74 | public function readFile() { |
75 | $this->xmlReader = new XMLReader; |
76 | $xr = $this->xmlReader; |
77 | $fileName = realpath( $this->fileName ); |
78 | $uri = 'file://' . str_replace( '%2F', '/', rawurlencode( $fileName ) ); |
79 | $xr->open( $uri ); |
80 | $xr->setParserProperty( XMLReader::SUBST_ENTITIES, true ); |
81 | $success = $this->doTopLevel(); |
82 | $xr->close(); |
83 | $this->xmlReader = null; |
84 | |
85 | return $success; |
86 | } |
87 | |
88 | /** |
89 | * Do the top-level document element, and return success. |
90 | * @return bool |
91 | */ |
92 | public function doTopLevel() { |
93 | $xr = $this->xmlReader; |
94 | |
95 | # Check document element |
96 | while ( $xr->read() && $xr->nodeType !== XMLReader::ELEMENT ) { |
97 | } |
98 | |
99 | if ( $xr->name != 'SecurePoll' ) { |
100 | wfDebug( __METHOD__ . ": invalid document element\n" ); |
101 | |
102 | return false; |
103 | } |
104 | |
105 | while ( $xr->read() ) { |
106 | if ( $xr->nodeType !== XMLReader::ELEMENT ) { |
107 | continue; |
108 | } |
109 | if ( $xr->name !== 'election' ) { |
110 | continue; |
111 | } |
112 | if ( !$this->doElection() ) { |
113 | return false; |
114 | } |
115 | } |
116 | |
117 | return true; |
118 | } |
119 | |
120 | /** |
121 | * Read an <election> element and position the cursor past the end of it. |
122 | * Return success. |
123 | * @return bool |
124 | */ |
125 | public function doElection() { |
126 | $xr = $this->xmlReader; |
127 | if ( $xr->isEmptyElement ) { |
128 | wfDebug( __METHOD__ . ": unexpected empty element\n" ); |
129 | |
130 | return false; |
131 | } |
132 | $xr->read(); |
133 | $electionInfo = false; |
134 | while ( $xr->nodeType !== XMLReader::NONE ) { |
135 | if ( $xr->nodeType === XMLReader::END_ELEMENT ) { |
136 | # Finished |
137 | return true; |
138 | } |
139 | if ( $xr->nodeType !== XMLReader::ELEMENT ) { |
140 | # Skip comments, intervening text, etc. |
141 | $xr->read(); |
142 | continue; |
143 | } |
144 | if ( $xr->name === 'configuration' ) { |
145 | # Load configuration |
146 | $electionInfo = $this->readEntity( 'election' ); |
147 | if ( $electionInfo === false ) { |
148 | return false; |
149 | } |
150 | continue; |
151 | } |
152 | |
153 | if ( $xr->name === 'vote' ) { |
154 | # Notify tallier of vote record if requested |
155 | if ( $this->voteCallback && $electionInfo && $electionInfo['id'] == $this->voteElectionId ) { |
156 | $record = $this->readStringElement(); |
157 | $status = call_user_func( $this->voteCallback, $this, $record ); |
158 | if ( !$status->isOK() ) { |
159 | $this->voteCallbackStatus = $status; |
160 | |
161 | return false; |
162 | } |
163 | } else { |
164 | $xr->next(); |
165 | } |
166 | continue; |
167 | } |
168 | |
169 | wfDebug( __METHOD__ . ": ignoring unrecognized element <{$xr->name}>\n" ); |
170 | $xr->next(); |
171 | } |
172 | wfDebug( __METHOD__ . ": unexpected end of stream\n" ); |
173 | |
174 | return false; |
175 | } |
176 | |
177 | /** |
178 | * Read an entity configuration element: <configuration>, <question> or |
179 | * <option>, and position the cursor past the end of it. |
180 | * |
181 | * This function operates recursively to read child elements. It returns |
182 | * the info array for the entity. |
183 | * @param string $entityType |
184 | * @return false|array |
185 | */ |
186 | public function readEntity( $entityType ) { |
187 | $xr = $this->xmlReader; |
188 | $info = [ 'type' => $entityType ]; |
189 | $messages = []; |
190 | $properties = []; |
191 | if ( $xr->isEmptyElement ) { |
192 | wfDebug( __METHOD__ . ": unexpected empty element\n" ); |
193 | $xr->read(); |
194 | |
195 | return false; |
196 | } |
197 | $xr->read(); |
198 | |
199 | while ( true ) { |
200 | if ( $xr->nodeType === XMLReader::NONE ) { |
201 | wfDebug( __METHOD__ . ": unexpected end of stream\n" ); |
202 | |
203 | return false; |
204 | } |
205 | if ( $xr->nodeType === XMLReader::END_ELEMENT ) { |
206 | # End of entity |
207 | $xr->read(); |
208 | break; |
209 | } |
210 | if ( $xr->nodeType !== XMLReader::ELEMENT ) { |
211 | # Intervening text, comments, etc. |
212 | $xr->read(); |
213 | continue; |
214 | } |
215 | if ( $xr->name === 'message' ) { |
216 | $name = $xr->getAttribute( 'name' ); |
217 | $lang = $xr->getAttribute( 'lang' ); |
218 | $value = $this->readStringElement(); |
219 | // @phan-suppress-next-line PhanTypeMismatchDimAssignment |
220 | $messages[$lang][$name] = $value; |
221 | continue; |
222 | } |
223 | if ( $xr->name == 'property' ) { |
224 | $name = $xr->getAttribute( 'name' ); |
225 | $value = $this->readStringElement(); |
226 | // @phan-suppress-next-line PhanTypeMismatchDimAssignment |
227 | $properties[$name] = $value; |
228 | continue; |
229 | } |
230 | |
231 | # Info elements |
232 | if ( in_array( $xr->name, self::$entityInfoKeys[$entityType] ) ) { |
233 | $name = $xr->name; |
234 | $value = $this->readStringElement(); |
235 | # Fix date format |
236 | if ( $name == 'startDate' || $name == 'endDate' ) { |
237 | $value = wfTimestamp( TS_MW, $value ); |
238 | } |
239 | $info[$name] = $value; |
240 | continue; |
241 | } |
242 | |
243 | # Child elements |
244 | if ( isset( self::$childTypes[$entityType][$xr->name] ) ) { |
245 | $infoKey = self::$childTypes[$entityType][$xr->name]; |
246 | $childInfo = $this->readEntity( $xr->name ); |
247 | if ( !$childInfo ) { |
248 | return false; |
249 | } |
250 | $info[$infoKey][] = $childInfo; |
251 | continue; |
252 | } |
253 | |
254 | wfDebug( __METHOD__ . ": ignoring unrecognized element <{$xr->name}>\n" ); |
255 | $xr->next(); |
256 | } |
257 | |
258 | if ( !isset( $info['id'] ) ) { |
259 | wfDebug( __METHOD__ . ": missing id element in <$entityType>\n" ); |
260 | |
261 | return false; |
262 | } |
263 | |
264 | # This has to be done after the element is fully parsed, or you |
265 | # have to require 'id' to be above any children in the XML doc. |
266 | $this->addParentIds( $info, $info['type'], $info['id'] ); |
267 | |
268 | $id = $info['id']; |
269 | if ( isset( $info['title'] ) ) { |
270 | $this->idsByName[$info['title']] = $id; |
271 | } |
272 | $this->entityInfo[$id] = $info; |
273 | foreach ( $messages as $lang => $values ) { |
274 | $this->messages[$lang][$id] = $values; |
275 | } |
276 | $this->properties[$id] = $properties; |
277 | |
278 | return $info; |
279 | } |
280 | |
281 | /** |
282 | * Propagate parent ids to child elements |
283 | * @param array &$info |
284 | * @param string $key |
285 | * @param int $id |
286 | */ |
287 | public function addParentIds( &$info, $key, $id ) { |
288 | foreach ( self::$childTypes[$info['type']] as $childType ) { |
289 | if ( isset( $info[$childType] ) ) { |
290 | foreach ( $info[$childType] as &$child ) { |
291 | $child[$key] = $id; |
292 | # Recurse |
293 | $this->addParentIds( $child, $key, $id ); |
294 | } |
295 | } |
296 | } |
297 | } |
298 | |
299 | /** |
300 | * When the cursor is positioned on an element node, this reads the entire |
301 | * element and returns the contents as a string. On return, the cursor is |
302 | * positioned past the end of the element. |
303 | * @return string |
304 | */ |
305 | public function readStringElement() { |
306 | $xr = $this->xmlReader; |
307 | if ( $xr->isEmptyElement ) { |
308 | $xr->read(); |
309 | |
310 | return ''; |
311 | } |
312 | $s = ''; |
313 | $level = 1; |
314 | while ( $xr->read() && $level ) { |
315 | if ( $xr->nodeType == XMLReader::TEXT ) { |
316 | $s .= $xr->value; |
317 | continue; |
318 | } |
319 | if ( $xr->nodeType == XMLReader::ELEMENT && !$xr->isEmptyElement ) { |
320 | $level++; |
321 | continue; |
322 | } |
323 | if ( $xr->nodeType == XMLReader::END_ELEMENT ) { |
324 | $level--; |
325 | continue; |
326 | } |
327 | } |
328 | |
329 | return $s; |
330 | } |
331 | |
332 | public function callbackValidVotes( $electionId, $callback, $voterId = null ) { |
333 | $this->voteCallback = $callback; |
334 | $this->voteElectionId = $electionId; |
335 | $this->voteCallbackStatus = Status::newGood(); |
336 | $success = $this->readFile(); |
337 | $this->voteCallback = $this->voteElectionId = null; |
338 | if ( !$this->voteCallbackStatus->isOK() ) { |
339 | return $this->voteCallbackStatus; |
340 | } elseif ( $success ) { |
341 | return Status::newGood(); |
342 | } else { |
343 | return Status::newFatal( 'securepoll-dump-file-corrupt' ); |
344 | } |
345 | } |
346 | } |