1<?php
2
3const ZNOTE_SERVERDATA_DIR = 'engine/XML';
4const ZNOTE_SERVERDATA_MONSTERS = 'engine/XML/monster';
5const ZNOTE_SERVERDATA_MAX_XML = 33554432;
6const ZNOTE_SERVERDATA_MAX_LUA = 1048576;
7const ZNOTE_SERVERDATA_MAX_ZIP = 33554432;
8const ZNOTE_SERVERDATA_MAX_UNZIP = 134217728;
9const ZNOTE_SERVERDATA_MAX_FILES = 4000;
10
11function serverdata_root(): string
12{
13 return dirname(__DIR__, 2);
14}
15
16/** "8M" and friends, as php.ini writes them, in bytes. */
17function serverdata_ini_bytes(string $value): int
18{
19 $value = trim($value);
20 if ($value === '') {
21 return 0;
22 }
23
24 $number = (int)$value;
25
26 switch (strtolower(substr($value, -1))) {
27 case 'g': return $number * 1073741824;
28 case 'm': return $number * 1048576;
29 case 'k': return $number * 1024;
30 }
31
32 return $number;
33}
34
35function serverdata_human_size(int $bytes): string
36{
37 if ($bytes >= 1048576) {
38 return number_format($bytes / 1048576, 1) . ' MB';
39 }
40 if ($bytes >= 1024) {
41 return number_format($bytes / 1024, 1) . ' KB';
42 }
43
44 return $bytes . ' B';
45}
46
47function serverdata_dir(): string
48{
49 return serverdata_root() . '/' . ZNOTE_SERVERDATA_DIR;
50}
51
52function serverdata_monster_dir(): string
53{
54 return serverdata_root() . '/' . ZNOTE_SERVERDATA_MONSTERS;
55}
56
57function serverdata_file(string $name): string
58{
59 return serverdata_dir() . '/' . $name;
60}
61
62function serverdata_sources(): array
63{
64 return array(
65 // A TFS config.lua carries the MySQL password, and engine/XML is served
66 // by the web server, so the file itself is never kept: it is read once
67 // from the upload and only the whitelisted values survive.
68 'config' => array(
69 'label' => t_default('acp.src.config.label', 'Server information'),
70 'file' => 'config.lua',
71 'cache' => 'engine/cache/luaconfig',
72 'accept' => '.lua',
73 'page' => 'serverinfo.php',
74 'store' => false,
75 'help' => t_default('acp.src.config.help', 'Your server config.lua. Only the whitelisted settings are kept - the file itself is parsed and discarded, never stored where it could be downloaded.')
76 ),
77 'stages' => array(
78 'label' => t_default('acp.src.stages.label', 'Experience stages'),
79 'file' => 'stages.xml',
80 'cache' => 'engine/cache/stages',
81 'accept' => '.xml',
82 'page' => 'serverinfo.php',
83 'help' => t_default('acp.src.stages.help', 'data/XML/stages.xml. Only used when stages are enabled in it or in config.lua.')
84 ),
85 'items' => array(
86 'label' => t_default('acp.src.items.label', 'Items'),
87 'file' => 'items.xml',
88 'cache' => 'engine/cache/items',
89 'accept' => '.xml',
90 'page' => 'items.php',
91 'help' => t_default('acp.src.items.help', 'data/items/items.xml. Only equipable items (slotType or weaponType) are published.')
92 ),
93 'spells' => array(
94 'label' => t_default('acp.src.spells.label', 'Spells'),
95 'file' => 'spells.xml',
96 'cache' => 'engine/cache/spells',
97 'accept' => '.xml',
98 'page' => 'spells.php',
99 'help' => t_default('acp.src.spells.help', 'data/spells/spells.xml. Monster spells and house spells are filtered out.')
100 ),
101 'creatures' => array(
102 'label' => t_default('acp.src.creatures.label', 'Monsters'),
103 'file' => 'monster/monsters.xml',
104 'cache' => 'engine/cache/creatures',
105 'accept' => '.xml,.zip',
106 'page' => 'creatures.php',
107 'help' => t_default('acp.src.creatures.help', 'monsters.xml only lists names and file paths. Upload a .zip of data/monster/ to get health, experience, speed and race.')
108 )
109 );
110}
111
112function serverdata_cache(string $key): ?Cache
113{
114 $sources = serverdata_sources();
115 if (!isset($sources[$key])) {
116 return null;
117 }
118
119 $cache = new Cache($sources[$key]['cache']);
120 $cache->useMemory(false);
121 $cache->setExpiration(PHP_INT_MAX);
122
123 return $cache;
124}
125
126function serverdata_load(string $key)
127{
128 $cache = serverdata_cache($key);
129 if ($cache === null) {
130 return false;
131 }
132
133 $loaded = $cache->load();
134 $loaded = is_array($loaded) ? $loaded : false;
135
136 if (function_exists('serverdata_apply_overrides')) {
137 $loaded = serverdata_apply_overrides($key, $loaded);
138 }
139
140 return $loaded;
141}
142
143function serverdata_store(string $key, array $value): bool
144{
145 $cache = serverdata_cache($key);
146 if ($cache === null) {
147 return false;
148 }
149
150 $cache->setContent($value);
151 serverdata_drop_derived($key);
152
153 return $cache->save();
154}
155
156function serverdata_forget(string $key): void
157{
158 $sources = serverdata_sources();
159 if (!isset($sources[$key])) {
160 return;
161 }
162
163 @unlink(serverdata_root() . '/' . $sources[$key]['cache'] . Cache::EXT);
164 serverdata_drop_derived($key);
165}
166
167/**
168 * monster_loot.php builds its own cache out of items.xml and the monster files,
169 * so it goes stale the moment either of those is written or removed.
170 */
171function serverdata_drop_derived(string $key): void
172{
173 if ($key === 'items' || $key === 'creatures') {
174 @unlink(serverdata_root() . '/engine/cache/monster_loot' . Cache::EXT);
175 }
176}
177
178function serverdata_parse_items(string $path)
179{
180 $xml = @simplexml_load_file($path);
181 if ($xml === false) {
182 return false;
183 }
184
185 $typeAttributes = array();
186 $items = array();
187
188 foreach ($xml as $type => $item) {
189 if (!isset($typeAttributes[$type])) {
190 $typeAttributes[$type] = array();
191 }
192
193 $attributes = array();
194 foreach ($item->attributes() as $name => $value) {
195 $attributes["$name"] = "$value";
196 }
197
198 unset($attributes['plural'], $attributes['editorsuffix'], $attributes['article']);
199
200 foreach (array_keys($attributes) as $attribute) {
201 if (!in_array($attribute, $typeAttributes[$type], true)) {
202 $typeAttributes[$type][] = $attribute;
203 }
204 }
205
206 $keys = array();
207 $itemAttributes = array();
208
209 foreach ($item as $node) {
210 foreach ($node->attributes() as $name => $value) {
211 if ($name === 'key') {
212 $keys[] = "$value";
213 }
214 }
215 }
216
217 $current = null;
218 foreach ($item as $node) {
219 foreach ($node->attributes() as $name => $value) {
220 $value = "$value";
221 if (in_array($value, $keys, true)) {
222 $current = $value;
223 } else if ($current !== null) {
224 $itemAttributes[$current] = $value;
225 }
226 }
227 }
228
229 if (!isset($itemAttributes['slotType']) && !isset($itemAttributes['weaponType'])) {
230 continue;
231 }
232
233 $id = $attributes['id'] ?? ($attributes['name'] ?? null);
234 if ($id === null) {
235 continue;
236 }
237
238 $items[$type][$id] = array('attributes' => $itemAttributes);
239 foreach ($typeAttributes[$type] as $attribute) {
240 $items[$type][$id][$attribute] = $attributes[$attribute] ?? false;
241 }
242 }
243
244 return $items;
245}
246
247function serverdata_parse_spells(string $path)
248{
249 $xml = @simplexml_load_file($path);
250 if ($xml === false) {
251 return false;
252 }
253
254 $typeAttributes = array();
255 $spells = array();
256
257 foreach ($xml as $type => $spell) {
258 if (!isset($typeAttributes[$type])) {
259 $typeAttributes[$type] = array();
260 }
261
262 $attributes = array();
263 foreach ($spell->attributes() as $name => $value) {
264 $attributes["$name"] = "$value";
265 }
266
267 unset($attributes['script'], $attributes['spellid'], $attributes['function']);
268
269 if (isset($attributes['level'])) {
270 $attributes['lvl'] = $attributes['level'];
271 }
272 if (isset($attributes['magiclevel'])) {
273 $attributes['maglv'] = $attributes['magiclevel'];
274 }
275
276 foreach (array_keys($attributes) as $attribute) {
277 if (!in_array($attribute, $typeAttributes[$type], true)) {
278 $typeAttributes[$type][] = $attribute;
279 }
280 }
281
282 $vocations = array();
283 foreach ($spell->vocation as $vocation) {
284 foreach ($vocation->attributes() as $name => $value) {
285 if ("$name" === 'name') {
286 $id = vocation_name_to_id("$value");
287 $vocations[] = ($id !== false) ? $id : "$value";
288 } else if ("$name" === 'id') {
289 $vocations[] = (int)"$value";
290 }
291 }
292 }
293
294 $words = $attributes['words'] ?? '';
295 $name = $attributes['name'] ?? '';
296
297 if (substr($words, 0, 3) === '###' || substr($name, 0, 5) === 'House' || $name === '') {
298 continue;
299 }
300
301 $spells[$type][$name] = array('vocations' => $vocations);
302 foreach ($typeAttributes[$type] as $attribute) {
303 $spells[$type][$name][$attribute] = $attributes[$attribute] ?? false;
304 }
305 }
306
307 foreach (array_keys($spells) as $type) {
308 usort($spells[$type], static function (array $a, array $b): int {
309 if (isset($a['lvl'], $b['lvl'])) {
310 return (int)$a['lvl'] - (int)$b['lvl'];
311 }
312 if (isset($a['maglv'], $b['maglv'])) {
313 return (int)$a['maglv'] - (int)$b['maglv'];
314 }
315 return -1;
316 });
317 }
318
319 return $spells;
320}
321
322function serverdata_parse_stages(string $path)
323{
324 $xml = @simplexml_load_file($path);
325 if ($xml === false) {
326 return false;
327 }
328
329 $stages = array();
330 foreach ($xml->config->attributes() as $name => $value) {
331 $stages["$name"] = "$value";
332 }
333
334 $stages['stages'] = array();
335 foreach ($xml->stage as $stage) {
336 $row = array();
337 foreach ($stage->attributes() as $name => $value) {
338 $row["$name"] = "$value";
339 }
340 $stages['stages'][] = $row;
341 }
342
343 return $stages;
344}
345
346function serverdata_config_whitelist(): array
347{
348 return array(
349 'worldType', 'hotkeyAimbotEnabled', 'protectionLevel', 'killsToRedSkull', 'killsToBlackSkull',
350 'pzLocked', 'removeChargesFromRunes', 'timeToDecreaseFrags', 'whiteSkullTime',
351 'stairJumpExhaustion', 'experienceByKillingPlayers', 'expFromPlayersLevelRange',
352 'loginProtocolPort', 'maxPlayers', 'motd', 'onePlayerOnlinePerAccount', 'deathLosePercent',
353 'housePriceEachSQM', 'houseRentPeriod', 'marketOfferDuration', 'premiumToCreateMarketOffer',
354 'maxMarketOffersAtATimePerPlayer', 'allowChangeOutfit', 'freePremium',
355 'kickIdlePlayerAfterMinutes', 'rateExp', 'rateSkill', 'rateLoot', 'rateMagic', 'rateSpawn',
356 'staminaSystem', 'experienceStages'
357 );
358}
359
360/**
361 * Arithmetic on a config.lua right-hand side, without eval(). Only digits and
362 * the four operators are accepted, so nothing from the file can ever run as
363 * code - the previous implementation eval()'d whatever it found here.
364 */
365function serverdata_eval_number(string $expression)
366{
367 $expression = trim($expression);
368 if ($expression === '' || !preg_match('/^[0-9+\-*\/(). ]+$/', $expression)) {
369 return null;
370 }
371 if (preg_match('/^-?\d+$/', $expression)) {
372 return (int)$expression;
373 }
374 if (preg_match('/^-?\d*\.\d+$/', $expression)) {
375 return (float)$expression;
376 }
377
378 $position = 0;
379 $chars = str_split(str_replace(' ', '', $expression));
380 $length = count($chars);
381
382 $parseExpression = null;
383
384 $parsePrimary = static function () use (&$chars, &$position, $length, &$parseExpression) {
385 if ($position < $length && $chars[$position] === '(') {
386 $position++;
387 $value = $parseExpression();
388 if ($position >= $length || $chars[$position] !== ')') {
389 throw new RuntimeException('Unbalanced brackets.');
390 }
391 $position++;
392 return $value;
393 }
394
395 $sign = 1;
396 while ($position < $length && ($chars[$position] === '-' || $chars[$position] === '+')) {
397 if ($chars[$position] === '-') {
398 $sign = -$sign;
399 }
400 $position++;
401 }
402
403 $number = '';
404 while ($position < $length && (ctype_digit($chars[$position]) || $chars[$position] === '.')) {
405 $number .= $chars[$position++];
406 }
407 if ($number === '' || !is_numeric($number)) {
408 throw new RuntimeException('Not a number.');
409 }
410
411 return $sign * (strpos($number, '.') !== false ? (float)$number : (int)$number);
412 };
413
414 $parseTerm = static function () use (&$chars, &$position, $length, $parsePrimary) {
415 $value = $parsePrimary();
416 while ($position < $length && ($chars[$position] === '*' || $chars[$position] === '/')) {
417 $operator = $chars[$position++];
418 $right = $parsePrimary();
419 if ($operator === '/') {
420 if ((float)$right === 0.0) {
421 throw new RuntimeException('Division by zero.');
422 }
423 $value /= $right;
424 } else {
425 $value *= $right;
426 }
427 }
428 return $value;
429 };
430
431 $parseExpression = static function () use (&$chars, &$position, $length, $parseTerm) {
432 $value = $parseTerm();
433 while ($position < $length && ($chars[$position] === '+' || $chars[$position] === '-')) {
434 $operator = $chars[$position++];
435 $right = $parseTerm();
436 $value = ($operator === '+') ? $value + $right : $value - $right;
437 }
438 return $value;
439 };
440
441 try {
442 $value = $parseExpression();
443 } catch (Throwable $e) {
444 return null;
445 }
446
447 return ($position === $length) ? $value : null;
448}
449
450function serverdata_parse_config(string $content, ?string &$error = null)
451{
452 $error = null;
453
454 if (trim($content) === '') {
455 $error = 'The config.lua is empty.';
456 return false;
457 }
458
459 $first = strpos($content, '{');
460 if ($first !== false) {
461 $last = strripos($content, '}');
462 if ($last === false) {
463 $error = 'Syntax error in config.lua: an opening { has no matching }.';
464 return false;
465 }
466 $content = substr($content, 0, $first) . substr($content, $last + 1);
467 }
468
469 $whitelist = array_fill_keys(serverdata_config_whitelist(), true);
470 $values = array();
471
472 foreach (preg_split('/\R/', $content) ?: array() as $line) {
473 if (strpos($line, '=') === false) {
474 continue;
475 }
476
477 $comment = strpos($line, '--');
478 if ($comment !== false) {
479 $line = substr($line, 0, $comment);
480 }
481
482 $line = trim($line);
483 if ($line === '') {
484 continue;
485 }
486
487 $parts = explode('=', $line, 2);
488 if (count($parts) < 2) {
489 continue;
490 }
491
492 $key = trim($parts[0]);
493 $raw = trim($parts[1]);
494
495 if (!isset($whitelist[$key])) {
496 continue;
497 }
498
499 $lower = strtolower($raw);
500 if ($lower === 'true' || $lower === 'false') {
501 $values[$key] = ($lower === 'true');
502 continue;
503 }
504
505 if (strpos($raw, '"') !== false || strpos($raw, "'") !== false) {
506 $values[$key] = trim(str_replace(array('"', "'"), '', $raw));
507 continue;
508 }
509
510 if (array_key_exists($raw, $values)) {
511 $values[$key] = $values[$raw];
512 continue;
513 }
514
515 $number = serverdata_eval_number($raw);
516 if ($number !== null) {
517 $values[$key] = $number;
518 }
519 }
520
521 if (!$values) {
522 $error = 'No recognised settings were found. Is this really a config.lua?';
523 return false;
524 }
525
526 return $values;
527}
528
529function serverdata_creature_source(): array
530{
531 $local = serverdata_monster_dir();
532 if (is_file($local . '/monsters.xml')) {
533 return array(
534 'index' => $local . '/monsters.xml',
535 'dir' => $local,
536 'label' => ZNOTE_SERVERDATA_MONSTERS
537 );
538 }
539
540 $path = rtrim((string)($GLOBALS['config']['server_path'] ?? ''), '/\\');
541 if ($path === '') {
542 $path = 'misc';
543 }
544
545 return array(
546 'index' => $path . '/data/monster/monsters.xml',
547 'dir' => $path . '/data/monster',
548 'label' => $path . '/data/monster'
549 );
550}
551
552function serverdata_parse_monsters(string $indexPath, string $dir, ?string &$error = null)
553{
554 $error = null;
555
556 $index = @simplexml_load_file($indexPath);
557 if ($index === false) {
558 $error = 'Could not read ' . basename($indexPath) . '.';
559 return false;
560 }
561
562 $creatures = array();
563 $missing = 0;
564
565 foreach ($index->monster as $entry) {
566 $file = (string)$entry['file'];
567 if ($file === '' || strpos($file, '..') !== false) {
568 continue;
569 }
570
571 $monster = @simplexml_load_file($dir . '/' . $file);
572 if ($monster === false) {
573 $missing++;
574 $name = trim((string)$entry['name']);
575 if ($name !== '') {
576 $creatures[] = array(
577 'name' => $name,
578 'health' => 0,
579 'experience' => 0,
580 'speed' => 0,
581 'race' => '',
582 'looktype' => 0
583 );
584 }
585 continue;
586 }
587
588 $creatures[] = array(
589 'name' => (string)($entry['name'] ?? $monster['name']),
590 'health' => isset($monster->health) ? (int)$monster->health['max'] : 0,
591 'experience' => (int)$monster['experience'],
592 'speed' => (int)$monster['speed'],
593 'race' => (string)$monster['race'],
594 'looktype' => isset($monster->look) ? (int)$monster->look['type'] : 0
595 );
596 }
597
598 if (!$creatures) {
599 $error = 'The index lists no monsters.';
600 return false;
601 }
602
603 usort($creatures, static function (array $a, array $b): int {
604 return strcasecmp($a['name'], $b['name']);
605 });
606
607 if ($missing) {
608 $error = $missing . ' of ' . count($creatures) . ' monster files were missing, so those rows have no stats. '
609 . 'Upload a .zip of data/monster/ to fill them in.';
610 }
611
612 return $creatures;
613}
614
615function serverdata_rebuild(string $key, ?string &$error = null): bool
616{
617 $error = null;
618 $sources = serverdata_sources();
619
620 if (!isset($sources[$key])) {
621 $error = 'Unknown data source.';
622 return false;
623 }
624
625 if ($key === 'creatures') {
626 $source = serverdata_creature_source();
627 if (!is_file($source['index'])) {
628 $error = 'No monsters.xml at ' . $source['label'] . '.';
629 return false;
630 }
631
632 $warning = null;
633 $creatures = serverdata_parse_monsters($source['index'], $source['dir'], $warning);
634 if ($creatures === false) {
635 $error = $warning;
636 return false;
637 }
638
639 if (!serverdata_store($key, $creatures)) {
640 $error = 'Could not write ' . $sources[$key]['cache'] . Cache::EXT . '.';
641 return false;
642 }
643
644 $error = $warning;
645 return true;
646 }
647
648 if (($sources[$key]['store'] ?? true) === false) {
649 $error = 'The ' . $sources[$key]['file'] . ' is not kept on disk, so there is nothing to re-read. Upload it again.';
650 return false;
651 }
652
653 $path = serverdata_file($sources[$key]['file']);
654 if (!is_file($path)) {
655 $error = 'No ' . ZNOTE_SERVERDATA_DIR . '/' . $sources[$key]['file'] . ' to read.';
656 return false;
657 }
658
659 $parser = 'serverdata_parse_' . $key;
660 $parsed = $parser($path);
661 if ($parsed === false) {
662 $error = ZNOTE_SERVERDATA_DIR . '/' . $sources[$key]['file'] . ' is not valid XML.';
663 }
664
665 if ($parsed === false) {
666 return false;
667 }
668
669 if (!serverdata_store($key, $parsed)) {
670 $error = 'Could not write ' . $sources[$key]['cache'] . Cache::EXT . '.';
671 return false;
672 }
673
674 return true;
675}
676
677function serverdata_count(string $key, $data): int
678{
679 if (!is_array($data)) {
680 return 0;
681 }
682
683 if ($key === 'creatures' || $key === 'config') {
684 return count($data);
685 }
686 if ($key === 'stages') {
687 return count($data['stages'] ?? array());
688 }
689
690 $total = 0;
691 foreach ($data as $group) {
692 $total += is_array($group) ? count($group) : 1;
693 }
694
695 return $total;
696}
697
698function serverdata_status(): array
699{
700 $status = array();
701
702 foreach (serverdata_sources() as $key => $source) {
703 $keeps = ($source['store'] ?? true) !== false;
704
705 if ($key === 'creatures') {
706 $origin = serverdata_creature_source();
707 $path = $origin['index'];
708 $label = $origin['label'] . '/monsters.xml';
709 $extra = count(glob(serverdata_monster_dir() . '/{,*/,*/*/}*.xml', GLOB_BRACE) ?: array());
710 } else {
711 $path = serverdata_file($source['file']);
712 $label = ZNOTE_SERVERDATA_DIR . '/' . $source['file'];
713 $extra = 0;
714 }
715
716 $data = serverdata_load($key);
717 $cache = serverdata_root() . '/' . $source['cache'] . Cache::EXT;
718 $onDisk = $keeps && is_file($path);
719
720 $status[$key] = $source + array(
721 'key' => $key,
722 'path' => $path,
723 'path_label' => $keeps ? $label : 'parsed on upload, not stored',
724 'keeps_file' => $keeps,
725 'uploaded' => $keeps ? $onDisk : ($data !== false),
726 'upload_date' => $onDisk ? (int)filemtime($path) : 0,
727 'upload_size' => $onDisk ? (int)filesize($path) : 0,
728 'cached' => ($data !== false),
729 'cache_date' => is_file($cache) ? (int)filemtime($cache) : 0,
730 'count' => serverdata_count($key, $data),
731 'files' => $extra
732 );
733 }
734
735 return $status;
736}
737
738function serverdata_store_upload(string $key, string $tmpFile, string $originalName, ?string &$error = null): bool
739{
740 $error = null;
741 $sources = serverdata_sources();
742
743 if (!isset($sources[$key])) {
744 $error = 'Unknown data source.';
745 return false;
746 }
747
748 $extension = strtolower((string)pathinfo($originalName, PATHINFO_EXTENSION));
749 $allowed = array_map(static function (string $e): string {
750 return ltrim(trim($e), '.');
751 }, explode(',', $sources[$key]['accept']));
752
753 if (!in_array($extension, $allowed, true)) {
754 $error = 'Expected a ' . implode(' or ', array_map(static function (string $e): string {
755 return '.' . $e;
756 }, $allowed)) . ' file.';
757 return false;
758 }
759
760 $size = (int)filesize($tmpFile);
761 if ($size < 1) {
762 $error = 'The uploaded file is empty.';
763 return false;
764 }
765
766 $limit = ($extension === 'lua') ? ZNOTE_SERVERDATA_MAX_LUA
767 : (($extension === 'zip') ? ZNOTE_SERVERDATA_MAX_ZIP : ZNOTE_SERVERDATA_MAX_XML);
768
769 if ($size > $limit) {
770 $error = 'That file is larger than the ' . (int)($limit / 1048576) . ' MB limit for .' . $extension . ' uploads.';
771 return false;
772 }
773
774 if (($sources[$key]['store'] ?? true) === false) {
775 $parsed = serverdata_parse_config((string)file_get_contents($tmpFile), $error);
776 if ($parsed === false) {
777 return false;
778 }
779 if (!serverdata_store($key, $parsed)) {
780 $error = 'Could not write ' . $sources[$key]['cache'] . Cache::EXT . '.';
781 return false;
782 }
783 return true;
784 }
785
786 $dir = ($key === 'creatures') ? serverdata_monster_dir() : serverdata_dir();
787 if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) {
788 $error = 'Could not create ' . $dir . '.';
789 return false;
790 }
791 if (!is_writable($dir)) {
792 $error = str_replace(serverdata_root() . '/', '', $dir) . '/ is not writable by the web server.';
793 return false;
794 }
795
796 if ($extension === 'zip') {
797 return serverdata_extract_monsters($tmpFile, $error);
798 }
799
800 $target = ($key === 'creatures')
801 ? $dir . '/monsters.xml'
802 : serverdata_file($sources[$key]['file']);
803
804 if ($extension === 'xml' && @simplexml_load_file($tmpFile) === false) {
805 $error = 'That file is not valid XML.';
806 return false;
807 }
808
809 if (!@copy($tmpFile, $target)) {
810 $error = 'Could not write ' . str_replace(serverdata_root() . '/', '', $target) . '.';
811 return false;
812 }
813
814 return true;
815}
816
817/**
818 * Take an upload and make it live in one step. A source that keeps its file
819 * still has to be parsed afterwards; one that does not - config.lua - was
820 * already published by the upload itself, so re-reading it would fail.
821 *
822 * $error is a warning, not a failure, when this returns true.
823 */
824function serverdata_publish_upload(string $key, string $tmpFile, string $originalName, ?string &$error = null): bool
825{
826 if (!serverdata_store_upload($key, $tmpFile, $originalName, $error)) {
827 return false;
828 }
829
830 $sources = serverdata_sources();
831 if (($sources[$key]['store'] ?? true) === false) {
832 $error = null;
833 return true;
834 }
835
836 return serverdata_rebuild($key, $error);
837}
838
839/**
840 * Unpack a data/monster/ archive. Only .xml entries are taken, paths are
841 * rebased on wherever monsters.xml sits inside the zip, and anything trying to
842 * escape the target folder is dropped.
843 */
844function serverdata_extract_monsters(string $zipFile, ?string &$error = null): bool
845{
846 $error = null;
847
848 if (!class_exists('ZipArchive')) {
849 $error = 'PHP needs the zip extension to unpack a monster archive.';
850 return false;
851 }
852
853 $zip = new ZipArchive();
854 if ($zip->open($zipFile) !== true) {
855 $error = 'That .zip could not be opened.';
856 return false;
857 }
858
859 $index = null;
860 for ($i = 0; $i < $zip->numFiles; $i++) {
861 $name = str_replace('\\', '/', (string)$zip->getNameIndex($i));
862 if (strtolower(basename($name)) === 'monsters.xml') {
863 if ($index === null || substr_count($name, '/') < substr_count($index, '/')) {
864 $index = $name;
865 }
866 }
867 }
868
869 if ($index === null) {
870 $zip->close();
871 $error = 'That .zip has no monsters.xml. Zip the contents of your data/monster/ folder.';
872 return false;
873 }
874
875 $prefix = (strpos($index, '/') === false) ? '' : substr($index, 0, strrpos($index, '/') + 1);
876 $dir = serverdata_monster_dir();
877
878 $total = 0;
879 $written = 0;
880
881 for ($i = 0; $i < $zip->numFiles; $i++) {
882 $stat = $zip->statIndex($i);
883 if (!$stat) {
884 continue;
885 }
886
887 $name = str_replace('\\', '/', (string)$stat['name']);
888 if (substr($name, -1) === '/' || strtolower((string)pathinfo($name, PATHINFO_EXTENSION)) !== 'xml') {
889 continue;
890 }
891 if ($prefix !== '' && strpos($name, $prefix) !== 0) {
892 continue;
893 }
894
895 $relative = ($prefix === '') ? $name : substr($name, strlen($prefix));
896 if ($relative === '' || strpos($relative, '..') !== false || $relative[0] === '/') {
897 continue;
898 }
899
900 $total += (int)$stat['size'];
901 if ($total > ZNOTE_SERVERDATA_MAX_UNZIP) {
902 $zip->close();
903 $error = 'That archive unpacks to more than ' . (int)(ZNOTE_SERVERDATA_MAX_UNZIP / 1048576) . ' MB.';
904 return false;
905 }
906 if (++$written > ZNOTE_SERVERDATA_MAX_FILES) {
907 $zip->close();
908 $error = 'That archive holds more than ' . ZNOTE_SERVERDATA_MAX_FILES . ' XML files.';
909 return false;
910 }
911
912 $target = $dir . '/' . $relative;
913 $parent = dirname($target);
914
915 if (!is_dir($parent) && !@mkdir($parent, 0755, true) && !is_dir($parent)) {
916 continue;
917 }
918
919 $contents = $zip->getFromIndex($i);
920 if ($contents === false) {
921 continue;
922 }
923
924 @file_put_contents($target, $contents);
925 }
926
927 $zip->close();
928
929 if (!$written) {
930 $error = 'No XML files were extracted from that archive.';
931 return false;
932 }
933
934 return true;
935}
936
937function serverdata_clear(string $key): void
938{
939 $sources = serverdata_sources();
940 if (!isset($sources[$key])) {
941 return;
942 }
943
944 serverdata_forget($key);
945
946 if ($key === 'creatures') {
947 serverdata_rmdir(serverdata_monster_dir());
948 return;
949 }
950
951 @unlink(serverdata_file($sources[$key]['file']));
952}
953
954function serverdata_rmdir(string $dir): void
955{
956 if (!is_dir($dir)) {
957 return;
958 }
959
960 $items = new RecursiveIteratorIterator(
961 new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
962 RecursiveIteratorIterator::CHILD_FIRST
963 );
964
965 foreach ($items as $item) {
966 $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
967 }
968
969 @rmdir($dir);
970}
971