1<?php
2require_once __DIR__ . '/extensions.php';
3
4/**
5 * Layout (theme) system.
6 *
7 * A theme is a folder under layouts/. It owns the chrome and, optionally, the
8 * markup of any page it wants to restyle. It never contains logic: the root
9 * pages keep doing the queries, the theme only decides how the result looks.
10 *
11 * layouts/<name>/
12 * theme.json name, author, version, description, update
13 * screenshot.png thumbnail shown in the admin panel
14 * shells/default.php the page frame: <html>, header, {{content}}, footer
15 * views/<page>.php the middle block of one root page
16 * pages/<name>.php a page this theme adds, at page.php?p=<name>
17 * assets/css|js|img the theme's own CSS/JS/images
18 *
19 * Anything a theme leaves out falls back to layouts/default/, so a theme can
20 * be nothing more than a shell and a stylesheet and the whole site still works.
21 *
22 * How a request renders
23 * ---------------------
24 * highscores.php
25 * theme_open() -> opens an output buffer
26 * ... page logic and output, view('highscores') ...
27 * theme_close() -> closes it, renders the shell
28 *
29 * Everything the page emits is captured, so the shell can wrap it even though
30 * the root pages include the header and footer sequentially. That is also why
31 * a page can pick a different shell halfway through: nothing has been sent yet.
32 */
33
34define('ZNOTE_THEME_FALLBACK', 'default');
35
36// ---------------------------------------------------------------------------
37// Where things live
38// ---------------------------------------------------------------------------
39
40/** Absolute path of the layouts/ directory. */
41function theme_root(): string {
42 // __DIR__ is engine/function, so two levels up is the project root.
43 return dirname(__DIR__, 2) . '/layouts';
44}
45
46/** Name of a theme folder, sanitised. Never lets a name escape layouts/. */
47function theme_sanitize(string $name): string {
48 $name = strtolower(trim($name));
49 return preg_match('/^[a-z0-9_-]{1,64}$/', $name) === 1 ? $name : '';
50}
51
52/**
53 * The theme in use. Reads znote_config, falls back to 'default' when the
54 * setting is missing, invalid, or points at a folder that is not there.
55 */
56function theme_active(): string {
57 static $active = null;
58 if ($active !== null) {
59 return $active;
60 }
61
62 $name = theme_sanitize((string)(function_exists('setting') ? setting('layout', '') : ''));
63
64 if ($name !== ''
65 && is_dir(theme_root() . '/' . $name)
66 && znote_extension_compatibility(theme_manifest($name))['compatible']
67 ) {
68 $active = $name;
69 } else {
70 $active = ZNOTE_THEME_FALLBACK;
71 }
72
73 return $active;
74}
75
76/** Absolute path to a theme folder. */
77function theme_path(?string $theme = null): string {
78 return theme_root() . '/' . ($theme ?? theme_active());
79}
80
81/** Web path to a theme folder, relative to the site root. */
82function theme_url(?string $theme = null): string {
83 return 'layouts/' . ($theme ?? theme_active());
84}
85
86/**
87 * The chain a file is looked up along: the theme, then its parent, then that
88 * parent's parent, and finally the default theme.
89 *
90 * A theme becomes a child by naming another in its theme.json:
91 *
92 * { "name": "Exodus Dark", "parent": "tibiacom_v1" }
93 *
94 * It then only has to ship what it changes. A stylesheet and two views on top
95 * of a full parent is a complete theme, and a fix in the parent reaches every
96 * child without touching them.
97 *
98 * Cycles and missing parents are ignored rather than fatal: a broken "parent"
99 * degrades to the default theme instead of taking the site down.
100 */
101function theme_chain(?string $theme = null): array {
102 static $chains = array();
103
104 $theme = $theme ?? theme_active();
105 if (isset($chains[$theme])) {
106 return $chains[$theme];
107 }
108
109 $chain = array();
110 $seen = array();
111 $next = $theme;
112
113 // 8 is far more nesting than anyone needs, and stops a cycle dead.
114 for ($depth = 0; $next !== '' && $depth < 8; $depth++) {
115 if (isset($seen[$next]) || !is_dir(theme_root() . '/' . $next)) {
116 break;
117 }
118 $seen[$next] = true;
119 $chain[] = $next;
120
121 $next = theme_sanitize((string)(theme_manifest($next)['parent'] ?? ''));
122 }
123
124 if (!in_array(ZNOTE_THEME_FALLBACK, $chain, true)) {
125 $chain[] = ZNOTE_THEME_FALLBACK;
126 }
127
128 return $chains[$theme] = $chain;
129}
130
131/**
132 * Resolve a file along the theme chain.
133 * Returns the absolute path, or null when no theme in the chain has it.
134 */
135function theme_file(string $relative): ?string {
136 $relative = ltrim($relative, '/');
137
138 foreach (theme_chain() as $theme) {
139 $candidate = theme_root() . '/' . $theme . '/' . $relative;
140 if (is_file($candidate)) {
141 return $candidate;
142 }
143 }
144
145 return null;
146}
147
148/**
149 * URL of a theme asset, with the same fallback as theme_file().
150 * Use it for every stylesheet, script and image in a shell or view:
151 *
152 * <link rel="stylesheet" href="<?= theme_asset('css/style.css') ?>">
153 */
154function theme_asset(string $relative): string {
155 $relative = znote_extension_relative_path($relative);
156 if ($relative === '') {
157 return '';
158 }
159 $urlRelative = znote_extension_url_path($relative);
160
161 foreach (theme_chain() as $theme) {
162 $diskPath = theme_root() . '/' . $theme . '/assets/' . $relative;
163 if (is_file($diskPath)) {
164 $mtime = @filemtime($diskPath);
165 $suffix = $mtime !== false ? '?v=' . $mtime : '';
166 return 'layouts/' . $theme . '/assets/' . $urlRelative . $suffix;
167 }
168 }
169
170 // Return the active theme's path anyway so a missing file is visible as a
171 // 404 in the browser console rather than silently resolving elsewhere.
172 return theme_url() . '/assets/' . $urlRelative;
173}
174
175// ---------------------------------------------------------------------------
176// Theme registry (used by the admin panel)
177// ---------------------------------------------------------------------------
178
179/** Read theme.json, tolerating a missing or malformed file. */
180function theme_manifest(string $name): array {
181 // Read once per request: theme_list() and theme_options() both want it,
182 // and with many themes installed that is a lot of redundant disk reads.
183 static $cache = array();
184 if (isset($cache[$name])) {
185 return $cache[$name];
186 }
187
188 $defaults = array(
189 'key' => $name,
190 'name' => ucfirst(str_replace(array('-', '_'), ' ', $name)),
191 'author' => '',
192 'version' => '',
193 'description' => '',
194 'update' => '',
195 'url' => '',
196 'requires' => array(),
197 );
198
199 $file = theme_root() . '/' . $name . '/theme.json';
200 if (!is_file($file)) {
201 return $cache[$name] = $defaults;
202 }
203
204 $data = json_decode((string)file_get_contents($file), true);
205
206 if (!is_array($data)) {
207 return $cache[$name] = $defaults;
208 }
209
210 $manifest = array_merge($defaults, $data, array('key' => $name));
211 $manifest['update'] = theme_repository_notes($manifest['update']);
212 $compatibility = znote_extension_compatibility($manifest);
213 $manifest['compatible'] = $compatibility['compatible'];
214 $manifest['compatibility_errors'] = $compatibility['errors'];
215 $manifest['requirements'] = $compatibility['requires'];
216
217 return $cache[$name] = $manifest;
218}
219
220/**
221 * Every installed theme, keyed by folder name.
222 * Folders starting with "_" are templates/examples and are listed but flagged.
223 */
224function theme_list(): array {
225 $themes = array();
226
227 foreach (glob(theme_root() . '/*', GLOB_ONLYDIR) ?: array() as $dir) {
228 $name = basename($dir);
229 if (theme_sanitize(ltrim($name, '_')) === '') {
230 continue;
231 }
232
233 $manifest = theme_manifest($name);
234 $compatibility = znote_extension_compatibility($manifest);
235 $manifest['compatible'] = $compatibility['compatible'];
236 $manifest['compatibility_errors'] = $compatibility['errors'];
237 $manifest['requirements'] = $compatibility['requires'];
238 $manifest['path'] = $dir;
239 $manifest['is_example'] = ($name[0] === '_');
240 $manifest['screenshot'] = is_file($dir . '/screenshot.png')
241 ? 'layouts/' . $name . '/screenshot.png'
242 : null;
243
244 $themes[$name] = $manifest;
245 }
246
247 ksort($themes);
248
249 return $themes;
250}
251
252// ---------------------------------------------------------------------------
253// Theme options
254//
255// A theme declares the settings it wants an admin to be able to change, in the
256// "options" array of its theme.json:
257//
258// "options": [
259// { "key": "discord_url", "label": "Discord invite", "type": "url" },
260// { "key": "whatsapp_text", "label": "Opening message", "type": "textarea",
261// "default": "Hello!", "help": "Shown in the chat bubble" }
262// ]
263//
264// The admin panel renders a form from that, and the theme reads a value with
265// theme_option('discord_url'). Values live in znote_config under
266// "theme:<theme>:<key>", so a theme keeps its settings when you switch away
267// and back, and nothing is ever written into the theme's files.
268//
269// Supported types: text, url, textarea, checkbox, image, datetime-local.
270// ---------------------------------------------------------------------------
271
272/** The options a theme declares, normalised. Keyed by option key. */
273function theme_options(?string $theme = null): array {
274 $theme = $theme ?? theme_active();
275 $manifest = theme_manifest($theme);
276 $declared = $manifest['options'] ?? null;
277
278 if (!is_array($declared)) {
279 return array();
280 }
281
282 $options = array();
283 foreach ($declared as $option) {
284 if (!is_array($option) || empty($option['key'])) {
285 continue;
286 }
287 $key = preg_replace('/[^a-z0-9_]/', '', strtolower((string)$option['key']));
288 if ($key === '') {
289 continue;
290 }
291
292 $type = strtolower((string)($option['type'] ?? 'text'));
293 if (!in_array($type, array('text', 'url', 'textarea', 'checkbox', 'image', 'datetime-local'), true)) {
294 $type = 'text';
295 }
296
297 $options[$key] = array(
298 'key' => $key,
299 'label' => (string)($option['label'] ?? $key),
300 'type' => $type,
301 'default' => (string)($option['default'] ?? ''),
302 'help' => (string)($option['help'] ?? ''),
303 // An image option may carry the CSS rule that puts it on the page,
304 // with %s where the URL goes. The engine then writes that rule into
305 // the head itself, so no theme file has to know about the option.
306 'css' => (string)($option['css'] ?? ''),
307 );
308 }
309
310 return $options;
311}
312
313/** Storage key for one theme option. */
314function theme_option_key(string $theme, string $key): string {
315 return 'theme:' . $theme . ':' . $key;
316}
317
318/**
319 * The value of a theme option: what the admin saved, else the declared
320 * default, else $fallback. Always a string - checkboxes give '1' or ''.
321 */
322function theme_option(string $key, string $fallback = '', ?string $theme = null): string {
323 $theme = $theme ?? theme_active();
324 $options = theme_options($theme);
325
326 if (!isset($options[$key])) {
327 return $fallback;
328 }
329
330 $stored = function_exists('setting') ? setting(theme_option_key($theme, $key), null) : null;
331
332 if ($stored !== null && $stored !== '') {
333 return $stored;
334 }
335
336 $default = $options[$key]['default'];
337
338 return ($default !== '') ? $default : $fallback;
339}
340
341/** True when the option holds something usable - handy for optional blocks. */
342function theme_option_set(string $key, ?string $theme = null): bool {
343 return trim(theme_option($key, '', $theme)) !== '';
344}
345
346// ---------------------------------------------------------------------------
347// Theme repository
348//
349// Lists themes hosted somewhere else and installs them into layouts/.
350//
351// A theme is PHP that runs on this server, so installing one means running code
352// written elsewhere. Three things are therefore enforced here rather than left
353// to whoever calls this:
354//
355// 1. https only, and the host must appear in $config['layout_repository']
356// ['allowed_hosts']. A catalogue pointing anywhere else is ignored, so a
357// tampered catalogue still cannot make this download from a random site.
358// 2. Every entry in the archive is checked before a single byte is written:
359// no absolute path, no "..", nothing outside the theme's own folder.
360// 3. An installed theme is never silently replaced, and a failed swap rolls
361// back to the previous copy.
362//
363// The catalogue is a JSON array; layouts/README.md documents its shape.
364// ---------------------------------------------------------------------------
365
366function theme_repository_config(): array {
367 global $config;
368 $cfg = $config['layout_repository'] ?? array();
369
370 return array(
371 'enabled' => !empty($cfg['enabled']),
372 'index' => trim((string)($cfg['index'] ?? '')),
373 'allowed_hosts' => array_map('strtolower', (array)($cfg['allowed_hosts'] ?? array())),
374 'cache_time' => max(60, (int)($cfg['cache_time'] ?? 3600)),
375 'max_size' => max(1, (int)($cfg['max_size_mb'] ?? 64)) * 1024 * 1024,
376 );
377}
378
379function theme_repository_cache_path(): string {
380 return 'engine/cache/layout_repository' . Cache::EXT;
381}
382
383function theme_repository_clear_cache(): bool {
384 $file = theme_repository_cache_path();
385
386 if (!is_file($file)) {
387 return true;
388 }
389
390 return @unlink($file);
391}
392
393/** True when a URL is https and points at a host on the allow list. */
394function theme_repository_url_allowed(string $url): bool {
395 $cfg = theme_repository_config();
396 $parts = parse_url($url);
397
398 if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) {
399 return false;
400 }
401
402 return in_array(strtolower($parts['host']), $cfg['allowed_hosts'], true);
403}
404
405/**
406 * GET a URL, with the guards above applied.
407 * Returns the body, or true when written to $toFile, or false with $error set.
408 */
409function theme_repository_get(string $url, ?string $toFile = null, ?string &$error = null) {
410 $cfg = theme_repository_config();
411
412 if (!theme_repository_url_allowed($url)) {
413 $error = 'Refused: the URL must be https and its host must be listed in $config[\'layout_repository\'][\'allowed_hosts\'].';
414 return false;
415 }
416 if (!function_exists('curl_init')) {
417 $error = 'The curl extension is not loaded.';
418 return false;
419 }
420
421 $ch = curl_init($url);
422 curl_setopt($ch, CURLOPT_RETURNTRANSFER, $toFile === null);
423 curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
424 curl_setopt($ch, CURLOPT_MAXREDIRS, 3);
425 curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 8);
426 curl_setopt($ch, CURLOPT_TIMEOUT, 120);
427 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
428 curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
429 curl_setopt($ch, CURLOPT_USERAGENT, 'ZnoteX/' . ($GLOBALS['version'] ?? '2.0.1'));
430
431 // The CA bundle shipped with ZnoteX, so this works on Windows too.
432 $ca = znote_cainfo();
433 if ($ca !== '') {
434 curl_setopt($ch, CURLOPT_CAINFO, $ca);
435 }
436
437 $handle = null;
438 if ($toFile !== null) {
439 $handle = @fopen($toFile, 'wb');
440 if ($handle === false) {
441 $error = 'Cannot write to ' . $toFile;
442 curl_close($ch);
443 return false;
444 }
445 curl_setopt($ch, CURLOPT_FILE, $handle);
446 // Abort rather than let a wrong or hostile URL fill the disk.
447 curl_setopt($ch, CURLOPT_NOPROGRESS, false);
448 curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function ($res, $dlTotal, $dlNow) use ($cfg) {
449 return ($dlNow > $cfg['max_size'] || $dlTotal > $cfg['max_size']) ? 1 : 0;
450 });
451 }
452
453 $body = curl_exec($ch);
454 $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
455 $errNo = curl_errno($ch);
456 $errStr = curl_error($ch);
457 curl_close($ch);
458
459 if ($handle !== null) {
460 fclose($handle);
461 }
462
463 if ($errNo !== 0) {
464 $error = ($errNo === 42 || $errNo === 23)
465 ? 'Download aborted: the file is larger than the configured limit.'
466 : 'Download failed (curl ' . $errNo . '): ' . $errStr;
467 return false;
468 }
469 if ($status < 200 || $status >= 300) {
470 $error = 'The server answered HTTP ' . $status . '.';
471 return false;
472 }
473
474 return $toFile === null ? $body : true;
475}
476
477function theme_repository_notes($value): string {
478 if (is_string($value) || is_numeric($value)) {
479 return trim((string)$value);
480 }
481
482 if (!is_array($value)) {
483 return '';
484 }
485
486 $lines = array();
487 foreach ($value as $key => $item) {
488 if (is_array($item)) {
489 $nested = theme_repository_notes($item);
490 if ($nested !== '') {
491 $lines[] = is_string($key) ? $key . ":\n" . $nested : $nested;
492 }
493 continue;
494 }
495
496 $text = trim((string)$item);
497 if ($text === '') {
498 continue;
499 }
500
501 $lines[] = is_string($key) ? $key . ': ' . $text : '- ' . $text;
502 }
503
504 return trim(implode("\n", $lines));
505}
506
507/** The on-disk cache, if it's still fresh (or $refresh forces past it). */
508function theme_repository_cached(Cache $cache, bool $refresh): ?array {
509 if ($refresh || $cache->hasExpired()) {
510 return null;
511 }
512 $cached = $cache->load();
513 return is_array($cached) ? $cached : null;
514}
515
516/**
517 * Fetches and JSON-decodes the catalogue index. Accepts both a bare array
518 * and {"themes": [...]}. Returns null (with $error set) on any failure.
519 */
520function theme_repository_fetch_raw(string $indexUrl, bool $refresh, ?string &$error): ?array {
521 if ($refresh) {
522 $indexUrl .= (strpos($indexUrl, '?') === false ? '?' : '&') . 'nocache=' . time();
523 }
524
525 $body = theme_repository_get($indexUrl, null, $error);
526 if ($body === false) {
527 return null;
528 }
529
530 $data = json_decode((string)$body, true);
531 if (!is_array($data)) {
532 $error = 'The catalogue is not valid JSON: ' . json_last_error_msg() . ' | Response: ' . substr((string)$body, 0, 200);
533 return null;
534 }
535
536 if (isset($data['themes']) && is_array($data['themes'])) {
537 $data = $data['themes'];
538 }
539
540 return $data;
541}
542
543/** Raw catalogue entries, validated and reshaped into the theme-list format the rest of the admin panel expects. */
544function theme_repository_normalize_entries(array $data): array {
545 $themes = array();
546
547 foreach ($data as $entry) {
548 if (!is_array($entry)) {
549 continue;
550 }
551 $key = theme_sanitize((string)($entry['key'] ?? ''));
552 if ($key === '') {
553 continue;
554 }
555
556 $download = trim((string)($entry['download'] ?? ''));
557 $screenshot = trim((string)($entry['screenshot'] ?? ''));
558 $changelog = '';
559 foreach (array('changelog', 'changes', 'release_notes', 'update') as $notesKey) {
560 if (array_key_exists($notesKey, $entry)) {
561 $changelog = theme_repository_notes($entry[$notesKey]);
562 break;
563 }
564 }
565
566 $themes[$key] = array(
567 'key' => $key,
568 'name' => (string)($entry['name'] ?? ucfirst($key)),
569 'author' => (string)($entry['author'] ?? ''),
570 'version' => (string)($entry['version'] ?? ''),
571 'requires' => $entry['requires'] ?? array(),
572 'description' => (string)($entry['description'] ?? ''),
573 'changelog' => $changelog,
574 'url' => (string)($entry['url'] ?? ''),
575 'screenshot' => theme_repository_url_allowed($screenshot) ? $screenshot : '',
576 'download' => $download,
577 'installable' => theme_repository_url_allowed($download),
578 );
579 }
580
581 ksort($themes);
582
583 return $themes;
584}
585
586/**
587 * The catalogue, normalised and cached on disk so opening the page does not
588 * hit the network every time.
589 *
590 * @return array{themes: array, error: string}
591 */
592function theme_repository_list(bool $refresh = false): array {
593 $cfg = theme_repository_config();
594
595 if (!$cfg['enabled'] || $cfg['index'] === '') {
596 return array('themes' => array(), 'error' => '');
597 }
598
599 $cache = new Cache('engine/cache/layout_repository');
600 $cache->useMemory(false);
601
602 $cached = theme_repository_cached($cache, $refresh);
603 if ($cached !== null) {
604 return array('themes' => $cached, 'error' => '', 'cached' => true);
605 }
606
607 $error = null;
608 $data = theme_repository_fetch_raw($cfg['index'], $refresh, $error);
609 if ($data === null) {
610 return array('themes' => array(), 'error' => (string)$error);
611 }
612
613 $themes = theme_repository_normalize_entries($data);
614
615 $cache->setContent($themes);
616 $cache->save();
617
618 return array('themes' => $themes, 'error' => '');
619}
620
621/** Recursive delete, used for staging and for rollback. */
622function znote_rrmdir(string $dir): void {
623 if (!is_dir($dir)) {
624 return;
625 }
626 $items = @scandir($dir);
627 if ($items === false) {
628 return;
629 }
630 foreach ($items as $item) {
631 if ($item === '.' || $item === '..') {
632 continue;
633 }
634 $path = $dir . '/' . $item;
635 is_dir($path) ? znote_rrmdir($path) : @unlink($path);
636 }
637 @rmdir($dir);
638}
639
640/**
641 * Open a theme archive and return its entries, whichever extension is around.
642 *
643 * ZipArchive is not enabled everywhere - UniServer ships it disabled, and some
644 * shared hosts leave it out - but PharData reads zip archives too and phar is
645 * compiled into PHP rather than being a loadable extension. Falling back to it
646 * means installing a theme never asks the admin to edit php.ini.
647 *
648 * @return array{names: string[], read: callable, close: callable}|string
649 * The listing and two closures, or a message on failure.
650 */
651function theme_archive_open(string $zipPath) {
652
653 if (class_exists('ZipArchive')) {
654 $zip = new ZipArchive();
655 if ($zip->open($zipPath) !== true) {
656 return 'The downloaded file is not a readable zip archive.';
657 }
658
659 $names = array();
660 for ($i = 0; $i < $zip->numFiles; $i++) {
661 $name = $zip->getNameIndex($i);
662 if ($name !== false && $name !== '') {
663 $names[] = str_replace(chr(92), '/', $name);
664 }
665 }
666
667 return array(
668 'names' => $names,
669 'read' => static function (string $name) use ($zip) { return $zip->getStream($name); },
670 'close' => static function () use ($zip) { $zip->close(); },
671 );
672 }
673
674 if (!class_exists('PharData')) {
675 return 'Neither the zip extension nor PharData is available, so archives cannot be unpacked.';
676 }
677
678 // Note on the two paths differing: PharData silently drops entries whose
679 // path escapes the archive root, so with the fallback the "Refused" check
680 // below never fires - the bad entry is simply not listed. The security
681 // property is the same either way (nothing is written outside layouts/);
682 // only the message differs. The check stays because it is the one that
683 // speaks when ZipArchive is in use, and ZipArchive does hand those paths
684 // over verbatim.
685 //
686 // PharData insists on a .zip/.tar extension; the download already has one.
687 try {
688 $phar = new PharData($zipPath);
689 } catch (Throwable $e) {
690 return 'The downloaded file is not a readable archive (' . $e->getMessage() . ').';
691 }
692
693 // PharData reports absolute paths, so the prefix has to be absolute too.
694 $realPath = realpath($zipPath);
695 $prefix = 'phar://' . str_replace(chr(92), '/', $realPath !== false ? $realPath : $zipPath) . '/';
696 $names = array();
697
698 try {
699 foreach (new RecursiveIteratorIterator($phar, RecursiveIteratorIterator::SELF_FIRST) as $file) {
700 $path = str_replace(chr(92), '/', $file->getPathname());
701 $rel = (strpos($path, $prefix) === 0) ? substr($path, strlen($prefix)) : $path;
702 if ($rel === '' || $rel === false) {
703 continue;
704 }
705 $names[] = $file->isDir() ? rtrim($rel, '/') . '/' : $rel;
706 }
707 } catch (Throwable $e) {
708 return 'Could not read the archive (' . $e->getMessage() . ').';
709 }
710
711 return array(
712 'names' => $names,
713 'read' => static function (string $name) use ($prefix) { return @fopen($prefix . $name, 'rb'); },
714 'close' => static function () { /* nothing to close */ },
715 );
716}
717
718/**
719 * Download and unpack one catalogue entry into layouts/.
720 *
721 * Returns '' on success, 'already-installed' when it exists and $overwrite is
722 * false, or a message explaining what stopped it.
723 */
724function theme_repository_install(string $key, bool $overwrite = false): string {
725 $key = theme_sanitize($key);
726 if ($key === '') {
727 return 'Invalid theme name.';
728 }
729 if ($key === ZNOTE_THEME_FALLBACK) {
730 return 'The default theme cannot be replaced from here.';
731 }
732 $catalogue = theme_repository_list();
733 if (!isset($catalogue['themes'][$key])) {
734 return 'That theme is not in the catalogue.';
735 }
736
737 $entry = $catalogue['themes'][$key];
738 $compatibility = znote_extension_compatibility($entry);
739 if (!$compatibility['compatible']) {
740 return implode(' ', $compatibility['errors']);
741 }
742
743 if (!$entry['installable']) {
744 return 'Its download URL is not https, or its host is not on the allow list.';
745 }
746
747 $target = theme_root() . '/' . $key;
748 if (is_dir($target) && !$overwrite) {
749 return 'already-installed';
750 }
751 if (!is_writable(theme_root())) {
752 return 'The layouts/ directory is not writable by PHP.';
753 }
754
755 $tmp = theme_root() . '/.' . $key . '.download.zip';
756 $err = null;
757 if (theme_repository_get($entry['download'], $tmp, $err) === false) {
758 @unlink($tmp);
759 return (string)$err;
760 }
761
762 $result = theme_archive_install($key, $tmp, $overwrite);
763 @unlink($tmp);
764
765 return $result;
766}
767
768/**
769 * Validate and unpack a theme archive that is already on disk.
770 *
771 * Split out from the download so the checks below can be exercised on their
772 * own, and so a zip that arrived some other way can be installed the same way.
773 *
774 * Returns '' on success, or a message explaining what stopped it.
775 */
776function theme_archive_install(string $key, string $zipPath, bool $overwrite = false): string {
777 $key = theme_sanitize($key);
778 if ($key === '' || $key === ZNOTE_THEME_FALLBACK) {
779 return 'Invalid theme name.';
780 }
781 $target = theme_root() . '/' . $key;
782 if (is_dir($target) && !$overwrite) {
783 return 'already-installed';
784 }
785
786 $tmp = $zipPath;
787
788 $archive = theme_archive_open($tmp);
789 if (is_string($archive)) {
790 return $archive;
791 }
792
793 // ---- validate every entry BEFORE writing anything --------------------
794 $files = array();
795 $prefix = null;
796
797 foreach ($archive['names'] as $name) {
798 if ($name === '') {
799 continue;
800 }
801
802 if ($name[0] === '/' || strpos($name, '../') !== false || strpos($name, ':') !== false) {
803 $archive['close']();
804 return 'Refused: the archive contains a path that would write outside layouts/ (' . $name . ').';
805 }
806
807 $files[] = $name;
808
809 // GitHub's "Download ZIP" wraps everything in one top folder; strip it.
810 $top = explode('/', $name)[0];
811 if ($prefix === null) {
812 $prefix = $top;
813 } elseif ($prefix !== $top) {
814 $prefix = '';
815 }
816 }
817
818 if (!$files) {
819 $archive['close']();
820 return 'The archive is empty.';
821 }
822
823 $strip = ($prefix !== null && $prefix !== '') ? strlen($prefix) + 1 : 0;
824
825 $hasShell = false;
826 foreach ($files as $name) {
827 if (substr($name, $strip) === 'shells/default.php') {
828 $hasShell = true;
829 break;
830 }
831 }
832 if (!$hasShell) {
833 $archive['close']();
834 return 'Refused: no shells/default.php in the archive, so this is not a usable theme.';
835 }
836
837 // ---- unpack into staging --------------------------------------------
838 $staging = theme_root() . '/.' . $key . '.staging';
839 znote_rrmdir($staging);
840 if (!@mkdir($staging, 0775, true)) {
841 $archive['close']();
842 return 'Could not create a staging directory inside layouts/.';
843 }
844
845 foreach ($files as $name) {
846 $relative = substr($name, $strip);
847 if ($relative === '' || $relative === false) {
848 continue;
849 }
850
851 $dest = $staging . '/' . $relative;
852
853 if (substr($name, -1) === '/') {
854 @mkdir($dest, 0775, true);
855 continue;
856 }
857
858 $dir = dirname($dest);
859 if (!is_dir($dir) && !@mkdir($dir, 0775, true)) {
860 continue;
861 }
862
863 $stream = $archive['read']($name);
864 if ($stream === false) {
865 continue;
866 }
867 $out = @fopen($dest, 'wb');
868 if ($out !== false) {
869 stream_copy_to_stream($stream, $out);
870 fclose($out);
871 }
872 fclose($stream);
873 }
874
875 $archive['close']();
876
877 if (!is_file($staging . '/shells/default.php')) {
878 znote_rrmdir($staging);
879 return 'The archive unpacked without a shells/default.php. Nothing was installed.';
880 }
881
882 // ---- swap in, with rollback -----------------------------------------
883 if (is_dir($target)) {
884 $backup = theme_root() . '/.' . $key . '.previous';
885 znote_rrmdir($backup);
886
887 if (!@rename($target, $backup)) {
888 znote_rrmdir($staging);
889 return 'Could not move the existing theme aside. Check permissions on layouts/' . $key . '.';
890 }
891 if (!@rename($staging, $target)) {
892 @rename($backup, $target);
893 znote_rrmdir($staging);
894 return 'Could not put the new theme in place. The previous one was restored.';
895 }
896 znote_rrmdir($backup);
897 } elseif (!@rename($staging, $target)) {
898 znote_rrmdir($staging);
899 return 'Could not create layouts/' . $key . '.';
900 }
901
902 return '';
903}
904
905/** Delete an installed theme. Returns '' on success. */
906function theme_uninstall(string $key): string {
907 $key = theme_sanitize($key);
908 if ($key === '' || $key === ZNOTE_THEME_FALLBACK) {
909 return 'That theme cannot be removed.';
910 }
911 if ($key === theme_active()) {
912 return 'That theme is active. Switch to another one first.';
913 }
914
915 $dir = theme_root() . '/' . $key;
916 if (!is_dir($dir)) {
917 return 'That theme is not installed.';
918 }
919
920 znote_rrmdir($dir);
921
922 return is_dir($dir) ? 'Could not remove layouts/' . $key . '. Check permissions.' : '';
923}
924
925// ---------------------------------------------------------------------------
926// Rendering
927// ---------------------------------------------------------------------------
928
929/**
930 * Which shell wraps this page. A view or a page can change it before the
931 * footer runs; after that it is too late and the call is ignored.
932 */
933function theme_shell(?string $name = null): string {
934 static $shell = 'default';
935
936 if ($name !== null) {
937 $clean = theme_sanitize($name);
938 if ($clean !== '') {
939 $shell = $clean;
940 }
941 }
942
943 return $shell;
944}
945
946/** Start capturing page output. Called by the root pages, via theme_open(). */
947function theme_open(): void {
948 if (theme_is_open()) {
949 return;
950 }
951
952 theme_is_open(true);
953 ob_start();
954
955 // A page that redirects or die()s between the header and the footer would
956 // otherwise leave the buffer unflushed and show a blank page.
957 register_shutdown_function('theme_close');
958}
959
960/** Internal open/closed flag. */
961function theme_is_open(?bool $set = null): bool {
962 static $open = false;
963 if ($set !== null) {
964 $open = $set;
965 }
966 return $open;
967}
968
969/**
970 * Stop capturing, render the shell around what was captured.
971 * Called by the root pages, and again on shutdown as a safety net.
972 */
973function theme_close(): void {
974 if (!theme_is_open()) {
975 return;
976 }
977 theme_is_open(false);
978
979 $content = ob_get_clean();
980 if ($content === false) {
981 $content = '';
982 }
983
984 $shell = theme_file('shells/' . theme_shell() . '.php')
985 ?? theme_file('shells/default.php');
986
987 if ($shell === null) {
988 // No shell anywhere: emit the content bare rather than a blank page.
989 echo $content;
990 return;
991 }
992
993 // $content is what the shell echoes where the page body goes.
994 theme_content($content);
995
996 // Plugins may add markup to the head and the end of the body. Done here by
997 // rewriting the shell's output rather than by asking every theme to call a
998 // function, so a plugin works with themes written before it existed.
999 $injectHead = function_exists('znote_hook_collect') ? znote_hook_collect('page.head') : '';
1000 $injectFoot = function_exists('znote_hook_collect') ? znote_hook_collect('page.footer') : '';
1001
1002 // Background, logo and favicon overrides set in the admin panel. Written
1003 // here rather than by each theme, so a theme only has to declare the option.
1004 $injectHead = theme_style_overrides() . theme_favicon_links() . $injectHead;
1005
1006 // A shell is included from inside this function, so without this it would
1007 // see none of the page's variables - unlike views and widgets, which do.
1008 extract($GLOBALS, EXTR_SKIP);
1009
1010 ob_start();
1011 include $shell;
1012 $page = (string)ob_get_clean();
1013
1014 $injectFoot = (function_exists('translate_inject') ? translate_inject() : '') . $injectFoot;
1015
1016 if ($injectHead === '' && $injectFoot === '') {
1017 echo $page;
1018 return;
1019 }
1020
1021 if ($injectHead !== '') {
1022 $page = preg_replace('#</head>#i', $injectHead . '</head>', $page, 1) ?? $page;
1023 }
1024 if ($injectFoot !== '') {
1025 $page = preg_replace('#</body>#i', $injectFoot . '</body>', $page, 1) ?? $page;
1026 }
1027
1028 echo $page;
1029}
1030
1031/**
1032 * Inside a shell, prints the page body.
1033 * (Called with an argument by theme_close() to store it - shells call it with
1034 * no argument.)
1035 */
1036function theme_content(?string $set = null): void {
1037 static $content = '';
1038
1039 if ($set !== null) {
1040 $content = $set;
1041 return;
1042 }
1043
1044 echo $content;
1045}
1046
1047/**
1048 * Render the view for a page: the markup that goes between the header and the
1049 * footer. Root pages call this instead of holding their own HTML.
1050 *
1051 * view('highscores');
1052 *
1053 * Looks for layouts/<active>/views/highscores.php, then the default theme's.
1054 * Variables in scope where view() was called are visible inside the view, plus
1055 * anything passed in $vars.
1056 */
1057function view(string $name, array $vars = array()): void {
1058 $clean = theme_sanitize($name);
1059 if ($clean === '') {
1060 return;
1061 }
1062
1063 $file = theme_file('views/' . $clean . '.php');
1064 if ($file === null) {
1065 error_log("[ZnoteX theme] no view found for '{$clean}' in layouts/" . theme_active() . '/views/');
1066 return;
1067 }
1068
1069 // The caller's variables, so a view can use $players, $config and friends
1070 // exactly as the page's own inline HTML used to.
1071 extract($GLOBALS, EXTR_SKIP);
1072 extract($vars, EXTR_OVERWRITE);
1073
1074 include $file;
1075}
1076
1077/**
1078 * Include a file from the theme chain, from inside a shell, view or page.
1079 *
1080 * Always use this rather than theme_path() . '/parts/x.php': theme_path()
1081 * points at the ACTIVE theme only, so a child theme that does not ship the
1082 * file would include nothing and the page would render without its frame.
1083 * theme_include() walks child -> parent -> default like everything else.
1084 *
1085 * <?php theme_include('parts/head.php'); ?>
1086 * <?php theme_include('parts/box.php', ['title' => $title]); ?>
1087 *
1088 * Returns false when no theme in the chain has the file.
1089 */
1090function theme_include(string $relative, array $vars = array()): bool {
1091 $file = theme_file($relative);
1092 if ($file === null) {
1093 error_log('[ZnoteX theme] no ' . $relative . ' anywhere in the chain: ' . implode(' -> ', theme_chain()));
1094 return false;
1095 }
1096
1097 extract($GLOBALS, EXTR_SKIP);
1098 // Anything the caller needs to hand over, since an include from inside a
1099 // function does not see the caller's local variables.
1100 extract($vars, EXTR_OVERWRITE);
1101
1102 include $file;
1103
1104 return true;
1105}
1106
1107/**
1108 * Optional helpers a shell may call. A theme that writes its own menu in
1109 * header markup simply never calls them, and they cost nothing.
1110 */
1111function theme_menu(): void {
1112 $file = theme_file('menu.php');
1113 if ($file !== null) {
1114 extract($GLOBALS, EXTR_SKIP);
1115 include $file;
1116 }
1117}
1118
1119function theme_sidebar(): void {
1120 $file = theme_file('aside.php');
1121 if ($file !== null) {
1122 extract($GLOBALS, EXTR_SKIP);
1123 include $file;
1124 }
1125}
1126
1127/** One widget by name, from the theme or the default theme. */
1128function widget(string $name): void {
1129 $clean = theme_sanitize($name);
1130 if ($clean === '') {
1131 return;
1132 }
1133
1134 $file = theme_file('widgets/' . $clean . '.php');
1135 if ($file !== null) {
1136 extract($GLOBALS, EXTR_SKIP);
1137 include $file;
1138 }
1139}
1140
1141// ---------------------------------------------------------------------------
1142// Small conveniences for shells
1143// ---------------------------------------------------------------------------
1144
1145function theme_title(): string {
1146 global $config;
1147 return htmlspecialchars((string)($config['site_title'] ?? 'ZnoteX'), ENT_QUOTES, 'UTF-8');
1148}
1149
1150/** e.g. "page_highscores" - lets a theme restyle one page from CSS alone. */
1151function theme_body_class(): string {
1152 global $page_filename;
1153
1154 $classes = array('theme-' . theme_active());
1155 if (!empty($page_filename)) {
1156 $classes[] = 'page_' . preg_replace('/[^a-zA-Z0-9_-]/', '', (string)$page_filename);
1157 }
1158
1159 return htmlspecialchars(implode(' ', $classes), ENT_QUOTES, 'UTF-8');
1160}
1161
1162
1163// ---------------------------------------------------------------------------
1164// Image options
1165// ---------------------------------------------------------------------------
1166
1167/**
1168 * Where uploaded theme images live. Outside layouts/, so updating a theme or
1169 * re-extracting its archive cannot take them with it.
1170 */
1171function theme_image_dir(string $theme): string {
1172 return dirname(__DIR__, 2) . '/engine/img/theme/' . theme_sanitize($theme);
1173}
1174
1175function theme_image_url_base(string $theme): string {
1176 return 'engine/img/theme/' . theme_sanitize($theme) . '/';
1177}
1178
1179/**
1180 * A value safe to drop inside url(...). The template comes from the theme, but
1181 * the value comes from the admin form, so anything that could close the url()
1182 * and start another declaration is refused.
1183 */
1184function theme_css_url(string $value): string {
1185 $value = trim($value);
1186
1187 if ($value === '' || preg_match('~[\s"\'()\\\\;{}<>]~', $value)) {
1188 return '';
1189 }
1190 if (preg_match('~^(https?:)?//~i', $value)) {
1191 return $value;
1192 }
1193 if (strpos($value, '..') === false && preg_match('#^[A-Za-z0-9._~/?=&-]+$#', $value)) {
1194 return $value;
1195 }
1196
1197 return '';
1198}
1199
1200/**
1201 * The style block for the active theme's image options: every option that
1202 * declares a css template and holds a value. theme_close() injects it into the
1203 * head, so this works even for a theme that knows nothing about it.
1204 */
1205function theme_style_overrides(?string $theme = null): string {
1206 $theme = $theme ?? theme_active();
1207 $rules = array();
1208
1209 foreach (theme_options($theme) as $key => $option) {
1210 if ($option['css'] === '' || strpos($option['css'], '%s') === false) {
1211 continue;
1212 }
1213
1214 $url = theme_css_url(theme_option($key, '', $theme));
1215 if ($url === '') {
1216 continue;
1217 }
1218
1219 $rules[] = str_replace('%s', $url, $option['css']);
1220 }
1221
1222 if (!$rules) {
1223 return '';
1224 }
1225
1226 return '<style id="znote-theme-options">' . "\n" . implode("\n", $rules) . "\n" . '</style>' . "\n";
1227}
1228
1229function theme_favicon_links(?string $theme = null): string {
1230 $theme = $theme ?? theme_active();
1231 if ($theme === 'void') {
1232 return '';
1233 }
1234
1235 $options = theme_options($theme);
1236 if (!isset($options['favicon']) || $options['favicon']['type'] !== 'image') {
1237 return '';
1238 }
1239
1240 $stored = function_exists('setting') ? setting(theme_option_key($theme, 'favicon'), null) : null;
1241 $value = ($stored !== null)
1242 ? trim($stored)
1243 : (string)$options['favicon']['default'];
1244
1245 $url = theme_css_url($value);
1246 if ($url === '') {
1247 $url = 'assets/img/znoteX.png';
1248 }
1249
1250 $href = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
1251 return '<link rel="icon" href="' . $href . '">' . "\n"
1252 . '<link rel="shortcut icon" href="' . $href . '">' . "\n"
1253 . '<link rel="apple-touch-icon" href="' . $href . '">' . "\n";
1254}
1255
1256const THEME_IMAGE_MAX_BYTES = 4194304;
1257
1258/**
1259 * Save an uploaded theme image and return the path to store in the option.
1260 * The extension comes from what GD actually recognises, never from the name.
1261 */
1262function theme_image_store(string $theme, string $key, string $tmpFile, ?string &$error = null): string {
1263 $error = null;
1264 $theme = theme_sanitize($theme);
1265 $key = preg_replace('/[^a-z0-9_]/', '', strtolower($key));
1266
1267 if ($theme === '' || $key === '') {
1268 $error = 'Invalid theme or option.';
1269 return '';
1270 }
1271 if (!is_file($tmpFile) || filesize($tmpFile) < 1) {
1272 $error = 'The uploaded file is empty.';
1273 return '';
1274 }
1275 if (filesize($tmpFile) > THEME_IMAGE_MAX_BYTES) {
1276 $error = 'Images must be ' . (int)(THEME_IMAGE_MAX_BYTES / 1048576) . ' MB or smaller.';
1277 return '';
1278 }
1279
1280 $info = @getimagesize($tmpFile);
1281 $types = array(
1282 IMAGETYPE_JPEG => 'jpg',
1283 IMAGETYPE_PNG => 'png',
1284 IMAGETYPE_GIF => 'gif',
1285 IMAGETYPE_WEBP => 'webp',
1286 );
1287 if (defined('IMAGETYPE_ICO')) {
1288 $types[IMAGETYPE_ICO] = 'ico';
1289 }
1290
1291 if (!$info || !isset($types[$info[2]])) {
1292 $error = 'Only JPG, PNG, GIF, WebP and ICO images are accepted.';
1293 return '';
1294 }
1295
1296 $dir = theme_image_dir($theme);
1297 if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) {
1298 $error = 'Could not create engine/img/theme/' . $theme . '/.';
1299 return '';
1300 }
1301 if (!is_writable($dir)) {
1302 $error = 'engine/img/theme/' . $theme . '/ is not writable by the web server.';
1303 return '';
1304 }
1305
1306 // One file per option: replacing an image never leaves the old one behind.
1307 foreach ($types as $extension) {
1308 if (is_file($dir . '/' . $key . '.' . $extension)) {
1309 @unlink($dir . '/' . $key . '.' . $extension);
1310 }
1311 }
1312
1313 $name = $key . '.' . $types[$info[2]];
1314 if (!@copy($tmpFile, $dir . '/' . $name)) {
1315 $error = 'Could not write the image.';
1316 return '';
1317 }
1318
1319 return theme_image_url_base($theme) . $name;
1320}
1321
1322/**
1323 * An image option ready to drop into a src="" or url(): validated the same way
1324 * as the CSS overrides, then HTML-escaped. Falls back when unset or refused.
1325 */
1326function theme_image(string $key, string $fallback = '', ?string $theme = null): string {
1327 $value = theme_css_url(theme_option($key, '', $theme));
1328
1329 return htmlspecialchars($value !== '' ? $value : $fallback, ENT_QUOTES, 'UTF-8');
1330}
1331