1 | <?php |
---|
2 | /** |
---|
3 | * Officeshots.org - Test your office documents in different applications |
---|
4 | * Copyright (C) 2009 Stichting Lone Wolves |
---|
5 | * Written by Sander Marechal <s.marechal@jejik.com> |
---|
6 | * |
---|
7 | * This program is free software: you can redistribute it and/or modify |
---|
8 | * it under the terms of the GNU Affero General Public License as |
---|
9 | * published by the Free Software Foundation, either version 3 of the |
---|
10 | * License, or (at your option) any later version. |
---|
11 | * |
---|
12 | * This program is distributed in the hope that it will be useful, |
---|
13 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
---|
14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
---|
15 | * GNU Affero General Public License for more details. |
---|
16 | * |
---|
17 | * You should have received a copy of the GNU Affero General Public License |
---|
18 | * along with this program. If not, see <http://www.gnu.org/licenses/>. |
---|
19 | */ |
---|
20 | |
---|
21 | // We need to access the Request model statically for it's state constants |
---|
22 | App::import('Model', 'Request'); |
---|
23 | |
---|
24 | /** |
---|
25 | * The jobs controller |
---|
26 | */ |
---|
27 | class JobsController extends AppController |
---|
28 | { |
---|
29 | /** @var array The components this controller uses */ |
---|
30 | public $components = array('AuthCert'); |
---|
31 | |
---|
32 | /** @var array The helpers that will be available on the view */ |
---|
33 | public $helpers = array('Html', 'Form', 'JobModel'); |
---|
34 | |
---|
35 | /** @var array The models used by this controller */ |
---|
36 | public $uses = array('Job', 'Factory', 'Request'); |
---|
37 | |
---|
38 | /** |
---|
39 | * You can't index jobs. Redirect to requests. |
---|
40 | * @return void |
---|
41 | */ |
---|
42 | public function index() |
---|
43 | { |
---|
44 | $this->redirect(array('controller' => 'requests', 'action' => 'index')); |
---|
45 | } |
---|
46 | |
---|
47 | /** |
---|
48 | * View one of your own jobs |
---|
49 | * |
---|
50 | * @param string $id The job ID |
---|
51 | * @return void |
---|
52 | */ |
---|
53 | public function view($id = null) |
---|
54 | { |
---|
55 | if (!$id) { |
---|
56 | $this->Session->setFlash(__('Invalid Job.', true)); |
---|
57 | $this->redirect(array('controller' => 'requests', 'action'=>'index')); |
---|
58 | } |
---|
59 | |
---|
60 | $this->Job->contain(array( |
---|
61 | 'Request', |
---|
62 | 'Request.Gallery', |
---|
63 | 'Platform', |
---|
64 | 'Application', |
---|
65 | 'Factory', |
---|
66 | 'Result', |
---|
67 | )); |
---|
68 | $job = $this->Job->read(null, $id); |
---|
69 | |
---|
70 | $isValid = ( |
---|
71 | !empty($job['Request']['Gallery']) || |
---|
72 | $job['Request']['user_id'] == $this->AuthCert->user('id') |
---|
73 | ); |
---|
74 | |
---|
75 | if (!$isValid) { |
---|
76 | $this->Session->setFlash(__('Invalid Job.', true)); |
---|
77 | $this->redirect(array('controller' => 'requests', 'action'=>'index')); |
---|
78 | } |
---|
79 | |
---|
80 | $this->set('job', $job); |
---|
81 | } |
---|
82 | |
---|
83 | /** |
---|
84 | * List all jobs |
---|
85 | * @return void |
---|
86 | */ |
---|
87 | public function admin_index() |
---|
88 | { |
---|
89 | $this->Job->recursive = 0; |
---|
90 | $this->set('jobs', $this->paginate()); |
---|
91 | } |
---|
92 | |
---|
93 | /** |
---|
94 | * View a single job |
---|
95 | * |
---|
96 | * @param string $id The job ID |
---|
97 | * @return void |
---|
98 | */ |
---|
99 | public function admin_view($id = null) |
---|
100 | { |
---|
101 | if (!$id) { |
---|
102 | $this->Session->setFlash(__('Invalid Job.', true)); |
---|
103 | $this->redirect(array('action'=>'index')); |
---|
104 | } |
---|
105 | $this->set('job', $this->Job->read(null, $id)); |
---|
106 | } |
---|
107 | |
---|
108 | /** |
---|
109 | * Add a new job manually |
---|
110 | * @return void |
---|
111 | */ |
---|
112 | public function admin_add() |
---|
113 | { |
---|
114 | if (!empty($this->data)) { |
---|
115 | $this->Job->create(); |
---|
116 | if ($this->Job->save($this->data)) { |
---|
117 | $this->Session->setFlash(__('The Job has been saved', true)); |
---|
118 | $this->redirect(array('action'=>'index')); |
---|
119 | } else { |
---|
120 | $this->Session->setFlash(__('The Job could not be saved. Please, try again.', true)); |
---|
121 | } |
---|
122 | } |
---|
123 | $requests = $this->Job->Request->find('list'); |
---|
124 | $platforms = $this->Job->Platform->find('list'); |
---|
125 | $applications = $this->Job->Application->find('list'); |
---|
126 | $factories = $this->Job->Factory->find('list'); |
---|
127 | $results = $this->Job->Result->find('list'); |
---|
128 | $this->set(compact('requests', 'platforms', 'applications', 'factories', 'results')); |
---|
129 | } |
---|
130 | |
---|
131 | /** |
---|
132 | * Edit a job |
---|
133 | * |
---|
134 | * @param string $id The job ID |
---|
135 | * @return void |
---|
136 | */ |
---|
137 | public function admin_edit($id = null) |
---|
138 | { |
---|
139 | if (!$id && empty($this->data)) { |
---|
140 | $this->Session->setFlash(__('Invalid Job', true)); |
---|
141 | $this->redirect(array('action'=>'index')); |
---|
142 | } |
---|
143 | if (!empty($this->data)) { |
---|
144 | if ($this->Job->save($this->data)) { |
---|
145 | $this->Session->setFlash(__('The Job has been saved', true)); |
---|
146 | $this->redirect(array('action'=>'index')); |
---|
147 | } else { |
---|
148 | $this->Session->setFlash(__('The Job could not be saved. Please, try again.', true)); |
---|
149 | } |
---|
150 | } |
---|
151 | if (empty($this->data)) { |
---|
152 | $this->data = $this->Job->read(null, $id); |
---|
153 | } |
---|
154 | $requests = $this->Job->Request->find('list'); |
---|
155 | $platforms = $this->Job->Platform->find('list'); |
---|
156 | $applications = $this->Job->Application->find('list'); |
---|
157 | $factories = $this->Job->Factory->find('list'); |
---|
158 | $results = $this->Job->Result->find('list'); |
---|
159 | $this->set(compact('requests', 'platforms', 'applications', 'factories', 'results')); |
---|
160 | } |
---|
161 | |
---|
162 | /** |
---|
163 | * Delete a job |
---|
164 | * |
---|
165 | * @param string $id The job ID |
---|
166 | * @return void |
---|
167 | */ |
---|
168 | public function admin_delete($id = null) |
---|
169 | { |
---|
170 | if (!$id) { |
---|
171 | $this->Session->setFlash(__('Invalid id for Job', true)); |
---|
172 | $this->redirect(array('action'=>'index')); |
---|
173 | } |
---|
174 | if ($this->Job->del($id)) { |
---|
175 | $this->Session->setFlash(__('Job deleted', true)); |
---|
176 | $this->redirect(array('action'=>'index')); |
---|
177 | } |
---|
178 | } |
---|
179 | |
---|
180 | /** |
---|
181 | * Find a job that matches any of the applications on a given factory and lock it |
---|
182 | * |
---|
183 | * Signature |
---|
184 | * ~~~~~~~~~ |
---|
185 | * jobs.poll(factory_name) |
---|
186 | * |
---|
187 | * Arguments |
---|
188 | * ~~~~~~~~~ |
---|
189 | * - factory_name (string): The name of the factory that you want to poll for |
---|
190 | * |
---|
191 | * Return value |
---|
192 | * ~~~~~~~~~~~~ |
---|
193 | * When a job is found that matches any of the workers that are running on the |
---|
194 | * requested factory then an array will be returned containing the following fields: |
---|
195 | * |
---|
196 | * - job (string): The GUID of the job |
---|
197 | * - application (string): The name of the application to run, e.g. "OpenOffice.org Writer" |
---|
198 | * - version (string): The version string of the application, e.g. "3.0" or "2003 SP2" |
---|
199 | * - pageStart (int): The first page to render in the output |
---|
200 | * - pageEnd (int): The last page to render in the output. If this is zero then it should be |
---|
201 | * rendered to the end of the document. |
---|
202 | * - format (string): 3-letter code specifying the desired output format (pdf, png or odf). If this |
---|
203 | * is empty then the factory can decide. |
---|
204 | * - filename (string): The original filename of the ODF document |
---|
205 | * - doctype (string): 3-letter code specifying the input type (odt, ods, odp) |
---|
206 | * - document (string): Base64-encoded contents of the document |
---|
207 | * |
---|
208 | * When no mathcing job has been found then an empty result is returned. |
---|
209 | * |
---|
210 | * Job locking |
---|
211 | * ~~~~~~~~~~~ |
---|
212 | * The matching job is locked for five minutes. This is to make sure that no jobs are processed by two factories at |
---|
213 | * the same time. If your factory takes longer to process a job, it is possible that somebody else will lock it. In |
---|
214 | * this case, your upload will fail. |
---|
215 | * |
---|
216 | * Example request |
---|
217 | * ~~~~~~~~~~~~~~~ |
---|
218 | * POST /xmlrpc HTTP/1.0 |
---|
219 | * Host: example.org |
---|
220 | * Content-Type: text/xml |
---|
221 | * Content-Length: 193 |
---|
222 | * |
---|
223 | * <?xml version='1.0'?> |
---|
224 | * <methodCall> |
---|
225 | * <methodName>jobs.finish</methodName> |
---|
226 | * <params> |
---|
227 | * <param><value><string>My factory</string></value></param> |
---|
228 | * </params> |
---|
229 | * </methodCall> |
---|
230 | * |
---|
231 | * Example response |
---|
232 | * ~~~~~~~~~~~~~~~~ |
---|
233 | * HTTP/1.1 200 OK |
---|
234 | * Content-Type: application/xml |
---|
235 | * Content-Length: 10164 |
---|
236 | * Connection: close |
---|
237 | * |
---|
238 | * <?xml version="1.0" encoding="iso-8859-1"?> |
---|
239 | * <methodResponse> |
---|
240 | * <params> |
---|
241 | * <param> |
---|
242 | * <value> |
---|
243 | * <struct> |
---|
244 | * <member> |
---|
245 | * <name>job</name> |
---|
246 | * <value><string>4975c9c6-79a0-43a1-8134-0ba5c0a80105</string></value> |
---|
247 | * </member> |
---|
248 | * <member> |
---|
249 | * <name>application</name> |
---|
250 | * <value><string>OpenOffice.org Writer</string></value> |
---|
251 | * </member> |
---|
252 | * <member> |
---|
253 | * <name>version</name> |
---|
254 | * <value><string>3.0</string></value> |
---|
255 | * </member> |
---|
256 | * <member> |
---|
257 | * <name>pageStart</name> |
---|
258 | * <value><string>1</string></value> |
---|
259 | * </member> |
---|
260 | * <member> |
---|
261 | * <name>pageEnd</name> |
---|
262 | * <value><string>0</string></value> |
---|
263 | * </member> |
---|
264 | * <member> |
---|
265 | * <name>format</name> |
---|
266 | * <value><string>pdf</string></value> |
---|
267 | * </member> |
---|
268 | * <member> |
---|
269 | * <name>filename</name> |
---|
270 | * <value><string>mydocument.odt</string></value> |
---|
271 | * </member> |
---|
272 | * <member> |
---|
273 | * <name>doctype</name> |
---|
274 | * <value><string>odt</string></value> |
---|
275 | * </member> |
---|
276 | * <member> |
---|
277 | * <name>document</name> |
---|
278 | * <value><string>UEsDBBQA ... AAAAAA==</string></value> |
---|
279 | * </member> |
---|
280 | * </struct> |
---|
281 | * </value> |
---|
282 | * <param> |
---|
283 | * </params> |
---|
284 | * </methodResponse> |
---|
285 | * |
---|
286 | * @param string $method The XMLRPC method name |
---|
287 | * @param mixed $params The XMLRPC parameters |
---|
288 | * @param mixed $userdata Data passed from the XMLRPC server |
---|
289 | * @return array The XMLRPC result or a fault array |
---|
290 | */ |
---|
291 | public function xmlrpc_poll($method, $params, $userdata = null) |
---|
292 | { |
---|
293 | if (!$factory = $this->Factory->findFactory($this->AuthCert->user('id'), $params[0])) { |
---|
294 | return array('faultCode' => 1, 'faultString' => 'Factory does not exist. Possible SSL authentication failure.'); |
---|
295 | } |
---|
296 | |
---|
297 | $this->Factory->id = $factory['Factory']['id']; |
---|
298 | $this->Factory->poll(); |
---|
299 | |
---|
300 | $jobs = array(); |
---|
301 | foreach ($factory['Worker'] as $worker) { |
---|
302 | $formats = array_merge(array(''), Set::extract('/Format/id', $worker)); |
---|
303 | $doctypes = array_merge(array(''), Set::extract('/Application/Doctype/id', $worker)); |
---|
304 | foreach ($formats as &$format) { $format = "'" . $format . "'"; } |
---|
305 | foreach ($doctypes as &$doctype) { $doctype = "'" . $doctype . "'"; } |
---|
306 | |
---|
307 | $jobSet = $this->Job->query("SELECT |
---|
308 | `Job`.`id`, |
---|
309 | `Job`.`version`, |
---|
310 | `Application`.`name`, |
---|
311 | `Request`.`id`, |
---|
312 | `Request`.`page_start`, |
---|
313 | `Request`.`page_end`, |
---|
314 | `Request`.`filename`, |
---|
315 | `Request`.`created`, |
---|
316 | `Request`.`priority`, |
---|
317 | `Request`.`own_factory`, |
---|
318 | `Request`.`path`, |
---|
319 | `Format`.`code`, |
---|
320 | `Doctype`.`code` |
---|
321 | FROM `jobs` as `Job` |
---|
322 | LEFT JOIN `requests` AS `Request` ON (`Job`.`request_id` = `Request`.`id`) |
---|
323 | LEFT JOIN `applications` AS `Application` ON (`Job`.`application_id` = `Application`.`id`) |
---|
324 | LEFT JOIN `formats` AS `Format` ON (`Job`.`format_id` = `Format`.`id`) |
---|
325 | LEFT JOIN `mimetypes` AS `Mimetype` on (`Request`.`mimetype_id` = `Mimetype`.`id`) |
---|
326 | LEFT JOIN `doctypes` AS `Doctype` on (`Mimetype`.`doctype_id` = `Doctype`.`id`) |
---|
327 | WHERE `Job`.`result_id` = '' |
---|
328 | AND `Job`.`locked` < '" . date('Y-m-d H:i:s') . "' |
---|
329 | AND `Job`.`platform_id` = '" . $factory['Operatingsystem']['platform_id'] . "' |
---|
330 | AND `Job`.`application_id` = '" . $worker['application_id'] . "' |
---|
331 | AND `Job`.`version` = '" . $worker['version'] . "' |
---|
332 | AND `Job`.`format_id` IN (" . implode(', ', $formats) . ") |
---|
333 | AND `Request`.`state` = " . Request::STATE_QUEUED . " |
---|
334 | AND `Request`.`expire` > '" . date('Y-m-d H:i:s') . "' |
---|
335 | AND `Mimetype`.`doctype_id` IN (" . implode(', ', $doctypes) . ") |
---|
336 | AND (`Request`.`own_factory` = 0 |
---|
337 | OR (`Request`.`own_factory` = 1 AND `Request`.`user_id` = '" . $this->AuthCert->user('id') . "') |
---|
338 | )"); |
---|
339 | $jobs = array_merge($jobs, $jobSet); |
---|
340 | } |
---|
341 | |
---|
342 | if (sizeof($jobs) == 0) { |
---|
343 | return null; |
---|
344 | } |
---|
345 | |
---|
346 | $jobs = Set::sort($jobs, '{n}.Request.created', SORT_ASC); |
---|
347 | $jobs = Set::sort($jobs, '{n}.Request.priority', SORT_ASC); |
---|
348 | $jobs = Set::sort($jobs, '{n}.Request.own_factory', SORT_DESC); |
---|
349 | $job = array_shift($jobs); |
---|
350 | |
---|
351 | $this->Request->id = $job['Request']['id']; |
---|
352 | $file = $this->Request->getPath(); |
---|
353 | if (!file_exists($file) || !is_readable($file)) { |
---|
354 | // Something went wrong. Expire the entire request |
---|
355 | $this->Request->saveField('expire', date('Y-m-d H:i:s', time() -1)); |
---|
356 | return array('faultCode' => 2, 'faultString' => 'Job document could not be read. Please poll again.'); |
---|
357 | } |
---|
358 | |
---|
359 | $job['Job']['locked'] = date('Y-m-d H:i:s', time() + Configure::read('Job.locktime')); |
---|
360 | $job['Job']['factory_id'] = $factory['Factory']['id']; |
---|
361 | $this->Job->save($job); |
---|
362 | |
---|
363 | $result = array( |
---|
364 | 'job' => $job['Job']['id'], |
---|
365 | 'application' => $job['Application']['name'], |
---|
366 | 'version' => $job['Job']['version'], |
---|
367 | 'pageStart' => $job['Request']['page_start'], |
---|
368 | 'pageEnd' => $job['Request']['page_end'], |
---|
369 | 'format' => ($job['Format'] ? $job['Format']['code'] : ''), |
---|
370 | 'filename' => $job['Request']['filename'], |
---|
371 | 'doctype' => $job['Doctype']['code'], |
---|
372 | 'document' => base64_encode(file_get_contents($file)) |
---|
373 | ); |
---|
374 | |
---|
375 | return $result; |
---|
376 | } |
---|
377 | |
---|
378 | /** |
---|
379 | * Finish a job by uploading the result |
---|
380 | * |
---|
381 | * Signature |
---|
382 | * ~~~~~~~~~ |
---|
383 | * jobs.finish(factory_name, job, format, document) |
---|
384 | * |
---|
385 | * Arguments |
---|
386 | * ~~~~~~~~~ |
---|
387 | * - factory_name (string): The name of the factory that processed the job. |
---|
388 | * - job (string): The job ID that was returned by jobs.poll(). |
---|
389 | * - format (string): 3-letter code specifying the format of the result (pdf, png or odf). |
---|
390 | * - document (string): Base64-encoded contents of the result document. |
---|
391 | * |
---|
392 | * Return value |
---|
393 | * ~~~~~~~~~~~~ |
---|
394 | * - result (string): The ID of the result |
---|
395 | * |
---|
396 | * Example request |
---|
397 | * ~~~~~~~~~~~~~~~ |
---|
398 | * POST /xmlrpc HTTP/1.0 |
---|
399 | * Host: example.org |
---|
400 | * Content-Type: text/xml |
---|
401 | * Content-Length: 10162 |
---|
402 | * |
---|
403 | * <?xml version='1.0'?> |
---|
404 | * <methodCall> |
---|
405 | * <methodName>jobs.finish</methodName> |
---|
406 | * <params> |
---|
407 | * <param><value><string>My factory</string></value></param> |
---|
408 | * <param><value><string>4975c9c6-79a0-43a1-8134-0ba5c0a80105</string></value></param> |
---|
409 | * <param><value><string>pdf</string></value></param> |
---|
410 | * <params><value><string>UEsDBBQA ... AAAAAA==</string></value></params> |
---|
411 | * </params> |
---|
412 | * </methodCall> |
---|
413 | * |
---|
414 | * Example response |
---|
415 | * ~~~~~~~~~~~~~~~~ |
---|
416 | * HTTP/1.1 200 OK |
---|
417 | * Content-Type: application/xml |
---|
418 | * Content-Length: 195 |
---|
419 | * Connection: close |
---|
420 | * |
---|
421 | * <?xml version="1.0" encoding="iso-8859-1"?> |
---|
422 | * <methodResponse> |
---|
423 | * <params> |
---|
424 | * <param><value><string>497d9737-a2cc-4682-9d90-0136c0a80105</string></value></param> |
---|
425 | * </params> |
---|
426 | * </methodResponse> |
---|
427 | * |
---|
428 | * @param string $method The XMLRPC method name |
---|
429 | * @param mixed &$params The XMLRPC parameters |
---|
430 | * @param mixed $userdata Data passed from the XMLRPC server |
---|
431 | * @return array The XMLRPC result or a fault array |
---|
432 | */ |
---|
433 | public function xmlrpc_finish($method, &$params, $userdata = null) |
---|
434 | { |
---|
435 | if (sizeof($params) != 4) { |
---|
436 | return array('faultCode' => 1, 'faultString' => 'Wrong parameter count.'); |
---|
437 | } |
---|
438 | list($factory_name, $job_id, $format_code) = $params; |
---|
439 | |
---|
440 | if (!$factory = $this->Factory->findFactory($this->AuthCert->user('id'), $factory_name)) { |
---|
441 | return array('faultCode' => 1, 'faultString' => 'Factory does not exist.'); |
---|
442 | } |
---|
443 | |
---|
444 | // Check the job |
---|
445 | $job = $this->Job->find('first', array( |
---|
446 | 'conditions' => array('Job.id' => $job_id), |
---|
447 | 'contain' => array( |
---|
448 | 'Request', |
---|
449 | 'Format', |
---|
450 | 'Application', |
---|
451 | 'Platform', |
---|
452 | ) |
---|
453 | )); |
---|
454 | |
---|
455 | if (!$job) { |
---|
456 | return array('faultCode' => 1, 'faultString' => 'Job does not exist.'); |
---|
457 | } |
---|
458 | |
---|
459 | if ($job['Job']['factory_id'] != $factory['Factory']['id']) { |
---|
460 | return array('faultCode' => 1, 'faultString' => 'Job lock expired and the job is taken by another factory.'); |
---|
461 | } |
---|
462 | |
---|
463 | // Check the return format |
---|
464 | $format = $this->Job->Format->find('first', array( |
---|
465 | 'recursive' => -1, |
---|
466 | 'conditions' => array('Format.code' => $format_code), |
---|
467 | )); |
---|
468 | |
---|
469 | if (!$format || ($job['Job']['format_id'] && $job['Format']['code'] != $format_code)) { |
---|
470 | return array('faultCode' => 1, 'faultString' => 'Wrong document format.'); |
---|
471 | } |
---|
472 | |
---|
473 | // TODO: The file extension stuff is an ugly kludge. It should do this through the Mimetype model |
---|
474 | $basename = basename($job['Request']['filename']); |
---|
475 | $basename = substr($basename, 0, strrpos($basename, '.')); |
---|
476 | if ($format_code == 'odf') { |
---|
477 | $filename = $basename . strrchr($job['Request']['filename'], '.'); |
---|
478 | } else { |
---|
479 | $filename = $basename . '.' . $format_code; |
---|
480 | } |
---|
481 | |
---|
482 | // Find out where to store the result |
---|
483 | $path = $job['Request']['root'] . DS; |
---|
484 | $path .= Inflector::slug($job['Application']['name']) . '_'; |
---|
485 | $path .= Inflector::slug($job['Job']['version']) . '_'; |
---|
486 | $path .= Inflector::slug($job['Platform']['name']); |
---|
487 | |
---|
488 | // Create the Result |
---|
489 | $this->Job->Result->create(); |
---|
490 | $this->Job->Result->set(array( |
---|
491 | 'format_id' => $format['Format']['id'], |
---|
492 | 'factory_id' => $factory['Factory']['id'], |
---|
493 | 'path' => $path |
---|
494 | )); |
---|
495 | |
---|
496 | if (!$this->Job->Result->setFileBuffer($params[3], $filename, 'convert.base64-decode')) { |
---|
497 | $errors = $this->Job->Result->Behaviors->File->errors; |
---|
498 | array_unshift($errors, 'The result file could not be stored'); |
---|
499 | return array('faultCode' => 1, 'faultString' => implode("\n", $errors)); |
---|
500 | } |
---|
501 | |
---|
502 | if (!$this->Job->Result->save()) { |
---|
503 | return array('faultCode' => 1, 'faultString' => 'The result could not be saved'); |
---|
504 | } |
---|
505 | |
---|
506 | // Update the Job with the new Result |
---|
507 | $this->Job->id = $job['Job']['id']; |
---|
508 | $this->Job->save(array('result_id' => $this->Job->Result->id)); |
---|
509 | |
---|
510 | return $this->Job->Result->id; |
---|
511 | } |
---|
512 | } |
---|
513 | |
---|
514 | ?> |
---|