Initial commit

ZnoteX / Commit #5

Commit Initial commit

Alex Alex committed 01/10/2026 09:20 main Full upload
481 files +128,311 -0
A engine/database/connect.php +510-0 View file
@@ -0,0 +1,510 @@
1+<?php
2+
3+$time = time();
4+if (!isset($version)) {
5+ $version = (string)require dirname(__DIR__) . '/version.php';
6+}
7+
8+if (!isset($GLOBALS['__znote_start_time'])) {
9+ $GLOBALS['__znote_start_time'] = microtime(true);
10+}
11+
12+if (!function_exists('elapsedTime')) {
13+ function elapsedTime(): float {
14+ return round(microtime(true) - $GLOBALS['__znote_start_time'], 4);
15+ }
16+}
17+
18+if (!function_exists('znote_database_wait_screen')) {
19+ function znote_database_error_reference(): string {
20+ try {
21+ return strtoupper(bin2hex(random_bytes(6)));
22+ } catch (Throwable $e) {
23+ return strtoupper(substr(hash('sha256', microtime(true) . '|' . mt_rand()), 0, 12));
24+ }
25+ }
26+
27+ function znote_database_request_context(): string {
28+ if (PHP_SAPI === 'cli') {
29+ return 'cli';
30+ }
31+
32+ $method = (string)($_SERVER['REQUEST_METHOD'] ?? 'GET');
33+ $uri = (string)($_SERVER['REQUEST_URI'] ?? '');
34+ $ip = (string)($_SERVER['REMOTE_ADDR'] ?? '');
35+
36+ return trim($method . ' ' . $uri . ($ip !== '' ? ' ip=' . $ip : ''));
37+ }
38+
39+ function znote_database_sql_preview(?string $sql): string {
40+ if ($sql === null || $sql === '') {
41+ return '';
42+ }
43+
44+ $preview = preg_replace('/\s+/', ' ', trim($sql)) ?? '';
45+ if (strlen($preview) > 900) {
46+ $preview = substr($preview, 0, 900) . '...';
47+ }
48+
49+ return $preview;
50+ }
51+
52+ function znote_database_log_error(string $type, string $message, ?string $sql = null, int|string $code = 0): string {
53+ $reference = znote_database_error_reference();
54+ $parts = array(
55+ '[ZnoteX DB]',
56+ 'ref=' . $reference,
57+ 'type=' . $type,
58+ 'code=' . (string)$code,
59+ 'context=' . znote_database_request_context(),
60+ 'message=' . preg_replace('/\s+/', ' ', trim($message)),
61+ );
62+
63+ $preview = znote_database_sql_preview($sql);
64+ if ($preview !== '') {
65+ $parts[] = 'sql=' . $preview;
66+ }
67+
68+ error_log(implode(' | ', $parts));
69+ return $reference;
70+ }
71+
72+ function znote_database_wait_screen(int $errorCode, string $errorMessage): void {
73+ global $config;
74+
75+ if (PHP_SAPI === 'cli') {
76+ die("Failed to connect to MySQL: (" . $errorCode . ") " . $errorMessage . PHP_EOL);
77+ }
78+
79+ $reference = znote_database_log_error('connection', $errorMessage, null, $errorCode);
80+
81+ if (!headers_sent()) {
82+ http_response_code(503);
83+ header('Retry-After: 5');
84+ header('Content-Type: text/html; charset=UTF-8');
85+ }
86+
87+ $siteTitle = htmlspecialchars((string)($config['site_title'] ?? 'ZnoteX'), ENT_QUOTES, 'UTF-8');
88+ $safeReference = htmlspecialchars($reference, ENT_QUOTES, 'UTF-8');
89+ $showDetails = !empty($config['security']['show_database_errors']);
90+ $safeDetails = $showDetails
91+ ? htmlspecialchars('MySQL ' . $errorCode . ': ' . $errorMessage, ENT_QUOTES, 'UTF-8')
92+ : '';
93+ ?>
94+<!DOCTYPE html>
95+<html lang="en">
96+<head>
97+ <meta charset="utf-8">
98+ <meta name="viewport" content="width=device-width, initial-scale=1">
99+ <meta http-equiv="refresh" content="5">
100+ <title><?= $siteTitle ?> - Connecting</title>
101+ <style>
102+ :root {
103+ color-scheme: dark;
104+ --bg: #101319;
105+ --panel: rgba(25, 31, 42, .86);
106+ --text: #eef3fb;
107+ --muted: #a9b4c5;
108+ --line: rgba(255, 255, 255, .12);
109+ --gold: #d9aa48;
110+ --ember: #ef6f4f;
111+ --blue: #6ea8ff;
112+ }
113+
114+ * {
115+ box-sizing: border-box;
116+ }
117+
118+ body {
119+ margin: 0;
120+ min-height: 100vh;
121+ display: grid;
122+ place-items: center;
123+ padding: 24px;
124+ background:
125+ radial-gradient(circle at 25% 20%, rgba(110, 168, 255, .16), transparent 34%),
126+ radial-gradient(circle at 78% 72%, rgba(217, 170, 72, .14), transparent 34%),
127+ linear-gradient(135deg, #101319 0%, #171b24 48%, #0f1218 100%);
128+ color: var(--text);
129+ font: 16px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
130+ }
131+
132+ .db-wait {
133+ width: min(560px, 100%);
134+ padding: 38px 32px;
135+ border: 1px solid var(--line);
136+ border-radius: 8px;
137+ background: var(--panel);
138+ box-shadow: 0 24px 80px rgba(0, 0, 0, .38);
139+ text-align: center;
140+ backdrop-filter: blur(14px);
141+ }
142+
143+ .db-orbit {
144+ position: relative;
145+ width: 94px;
146+ height: 94px;
147+ margin: 0 auto 26px;
148+ border-radius: 50%;
149+ background: conic-gradient(from 90deg, var(--gold), var(--ember), var(--blue), var(--gold));
150+ animation: spin 1.25s linear infinite;
151+ }
152+
153+ .db-orbit::before {
154+ content: "";
155+ position: absolute;
156+ inset: 9px;
157+ border-radius: inherit;
158+ background: #151a22;
159+ box-shadow: inset 0 0 24px rgba(255, 255, 255, .05);
160+ }
161+
162+ .db-orbit::after {
163+ content: "";
164+ position: absolute;
165+ top: 7px;
166+ left: 50%;
167+ width: 14px;
168+ height: 14px;
169+ border-radius: 50%;
170+ background: #fff7d6;
171+ box-shadow: 0 0 22px rgba(217, 170, 72, .92);
172+ transform: translateX(-50%);
173+ }
174+
175+ h1 {
176+ margin: 0 0 12px;
177+ font-size: clamp(26px, 4vw, 38px);
178+ line-height: 1.12;
179+ font-weight: 800;
180+ letter-spacing: 0;
181+ }
182+
183+ p {
184+ margin: 0;
185+ color: var(--muted);
186+ }
187+
188+ .db-status {
189+ display: inline-flex;
190+ align-items: center;
191+ gap: 10px;
192+ margin-top: 24px;
193+ padding: 10px 14px;
194+ border: 1px solid var(--line);
195+ border-radius: 999px;
196+ background: rgba(255, 255, 255, .05);
197+ color: #dce5f2;
198+ font-size: 14px;
199+ }
200+
201+ .db-pulse {
202+ width: 9px;
203+ height: 9px;
204+ border-radius: 50%;
205+ background: var(--gold);
206+ box-shadow: 0 0 0 rgba(217, 170, 72, .7);
207+ animation: pulse 1.45s ease-out infinite;
208+ flex: 0 0 auto;
209+ }
210+
211+ .db-details {
212+ margin-top: 22px;
213+ padding-top: 18px;
214+ border-top: 1px solid var(--line);
215+ color: #7f8a9d;
216+ font-size: 13px;
217+ word-break: break-word;
218+ }
219+
220+ @keyframes spin {
221+ to { transform: rotate(360deg); }
222+ }
223+
224+ @keyframes pulse {
225+ 70% { box-shadow: 0 0 0 12px rgba(217, 170, 72, 0); }
226+ 100% { box-shadow: 0 0 0 0 rgba(217, 170, 72, 0); }
227+ }
228+
229+ @media (max-width: 520px) {
230+ body {
231+ padding: 16px;
232+ }
233+
234+ .db-wait {
235+ padding: 30px 22px;
236+ }
237+ }
238+ </style>
239+</head>
240+<body>
241+ <main class="db-wait" role="status" aria-live="polite">
242+ <div class="db-orbit" aria-hidden="true"></div>
243+ <h1><?= $siteTitle ?></h1>
244+ <p>The database connection is not ready yet.</p>
245+ <div class="db-status"><span class="db-pulse" aria-hidden="true"></span>Retrying automatically in 5 seconds</div>
246+ <div class="db-details">
247+ Reference: <?= $safeReference ?>
248+ <?php if ($safeDetails !== ''): ?><br><?= $safeDetails ?><?php endif; ?>
249+ </div>
250+ </main>
251+</body>
252+</html>
253+ <?php
254+ exit;
255+ }
256+}
257+
258+mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
259+
260+try {
261+ $connect = new mysqli(
262+ $config['sqlHost'],
263+ $config['sqlUser'],
264+ $config['sqlPassword'],
265+ $config['sqlDatabase']
266+ );
267+ // Use the full UTF-8 character set for every request. This keeps accents,
268+ // supplementary characters and emoji consistent regardless of the server's
269+ // global MySQL/MariaDB defaults.
270+ $connect->set_charset('utf8mb4');
271+ $connect->query("SET collation_connection = 'utf8mb4_general_ci'");
272+} catch (mysqli_sql_exception $e) {
273+ znote_database_wait_screen($e->getCode(), $e->getMessage());
274+}
275+
276+if ($connect->connect_errno) {
277+ znote_database_wait_screen($connect->connect_errno, (string)$connect->connect_error);
278+}
279+
280+if (!isset($aacQueries)) {
281+ $aacQueries = 0;
282+}
283+if (!isset($accQueriesData)) {
284+ $accQueriesData = [];
285+}
286+
287+class ZnoteDatabase {
288+ private mysqli $connection;
289+
290+ public function __construct(mysqli $connection) {
291+ $this->connection = $connection;
292+ }
293+
294+ public function connection(): mysqli {
295+ return $this->connection;
296+ }
297+
298+ public function fetchOne(string $sql, array $params = []): array|false {
299+ $rows = $this->fetchAll($sql, $params);
300+ return ($rows !== false && isset($rows[0])) ? $rows[0] : false;
301+ }
302+
303+ public function fetchAll(string $sql, array $params = []): array|false {
304+ $result = $this->query($sql, $params);
305+ if (!($result instanceof mysqli_result)) {
306+ return false;
307+ }
308+
309+ $rows = [];
310+ while ($row = $result->fetch_assoc()) {
311+ $rows[] = $row;
312+ }
313+ $result->free();
314+
315+ return $rows ?: false;
316+ }
317+
318+ public function execute(string $sql, array $params = []): bool {
319+ $result = $this->query($sql, $params);
320+ if ($result instanceof mysqli_result) {
321+ $result->free();
322+ }
323+
324+ return $result !== false;
325+ }
326+
327+ public function insertId(): int|string {
328+ return $this->connection->insert_id;
329+ }
330+
331+ public function affectedRows(): int|string {
332+ return $this->connection->affected_rows;
333+ }
334+
335+ public function transaction(callable $callback): mixed {
336+ try {
337+ $this->connection->begin_transaction();
338+ $result = $callback($this);
339+
340+ if ($result === false) {
341+ $this->connection->rollback();
342+ return false;
343+ }
344+
345+ $this->connection->commit();
346+ return $result;
347+ } catch (Throwable $e) {
348+ $this->connection->rollback();
349+ znote_database_log_error('transaction', $e->getMessage(), null, (int)$e->getCode());
350+ return false;
351+ }
352+ }
353+
354+ public function beginTransaction(): bool {
355+ return $this->connection->begin_transaction();
356+ }
357+
358+ public function commit(): bool {
359+ return $this->connection->commit();
360+ }
361+
362+ public function rollback(): bool {
363+ return $this->connection->rollback();
364+ }
365+
366+ public function rawFetchOne(string $sql): array|false {
367+ $result = $this->rawQuery($sql);
368+ if (!($result instanceof mysqli_result)) {
369+ return false;
370+ }
371+
372+ $row = $result->fetch_assoc();
373+ $result->free();
374+
375+ return $row ?: false;
376+ }
377+
378+ public function rawFetchAll(string $sql): array|false {
379+ $result = $this->rawQuery($sql);
380+ if (!($result instanceof mysqli_result)) {
381+ return false;
382+ }
383+
384+ $rows = [];
385+ while ($row = $result->fetch_assoc()) {
386+ $rows[] = $row;
387+ }
388+ $result->free();
389+
390+ return $rows ?: false;
391+ }
392+
393+ public function rawExecute(string $sql): bool {
394+ $result = $this->rawQuery($sql);
395+ if ($result instanceof mysqli_result) {
396+ $result->free();
397+ }
398+
399+ return $result !== false;
400+ }
401+
402+ private function query(string $sql, array $params = []): mysqli_result|bool {
403+ $this->logQuery($sql, $params);
404+
405+ try {
406+ if ($params === []) {
407+ return $this->connection->query($sql);
408+ }
409+
410+ $stmt = $this->connection->prepare($sql);
411+ $this->bindParams($stmt, $params);
412+ $stmt->execute();
413+
414+ $result = $this->statementResult($stmt);
415+ $stmt->close();
416+
417+ return $result;
418+ } catch (mysqli_sql_exception $e) {
419+ znote_database_log_error('prepared-query', $e->getMessage(), $sql, (int)$e->getCode());
420+ return false;
421+ }
422+ }
423+
424+ private function rawQuery(string $sql): mysqli_result|bool {
425+ $this->logQuery($sql);
426+
427+ try {
428+ return $this->connection->query($sql);
429+ } catch (mysqli_sql_exception $e) {
430+ znote_database_log_error('raw-query', $e->getMessage(), $sql, (int)$e->getCode());
431+ return false;
432+ }
433+ }
434+
435+ private function logQuery(string $sql, array $params = []): void {
436+ global $aacQueries, $accQueriesData;
437+
438+ $aacQueries++;
439+ $accQueriesData[] = '[' . elapsedTime() . '] ' . $sql . ($params === [] ? '' : ' [prepared params: ' . count($params) . ']');
440+ }
441+
442+ private function bindParams(mysqli_stmt $stmt, array $params): void {
443+ $types = '';
444+ $values = [];
445+
446+ foreach ($params as $param) {
447+ if (is_int($param) || is_bool($param)) {
448+ $types .= 'i';
449+ $values[] = (int)$param;
450+ } elseif (is_float($param)) {
451+ $types .= 'd';
452+ $values[] = $param;
453+ } else {
454+ $types .= 's';
455+ $values[] = $param;
456+ }
457+ }
458+
459+ if ($types === '') {
460+ return;
461+ }
462+
463+ $refs = [];
464+ foreach ($values as $key => &$value) {
465+ $refs[$key] = &$value;
466+ }
467+
468+ $stmt->bind_param($types, ...$refs);
469+ }
470+
471+ private function statementResult(mysqli_stmt $stmt): mysqli_result|bool {
472+ $result = $stmt->get_result();
473+ if ($result instanceof mysqli_result) {
474+ return $result;
475+ }
476+
477+ return true;
478+ }
479+}
480+
481+function db(): ZnoteDatabase {
482+ global $znoteDatabase, $connect;
483+
484+ if (!isset($znoteDatabase)) {
485+ $znoteDatabase = new ZnoteDatabase($connect);
486+ }
487+
488+ return $znoteDatabase;
489+}
490+
491+function mysql_znote_escape_string($escapestr): string {
492+ global $connect;
493+ return mysqli_real_escape_string($connect, (string)($escapestr ?? ''));
494+}
495+
496+function mysql_select_single(string $query): array|false {
497+ return db()->rawFetchOne($query);
498+}
499+
500+function mysql_select_multi(string $query): array|false {
501+ return db()->rawFetchAll($query);
502+}
503+
504+function voidQuery(string $query): bool {
505+ return db()->rawExecute($query);
506+}
507+
508+function mysql_update(string $query): bool { return voidQuery($query); }
509+function mysql_insert(string $query): bool { return voidQuery($query); }
510+function mysql_delete(string $query): bool { return voidQuery($query); }
A engine/footer.php +10-0 View file
@@ -0,0 +1,10 @@
1+<footer>
2+ &copy; Znote AAC.
3+ <?php
4+ $finish = microtime(true);
5+ $total_time = round($finish - $start, 4);
6+
7+ echo 'Server date and clock is: ' . getClock(false, true) .
8+ ' Page generated in ' . $total_time . ' seconds.';
9+ ?>
10+</footer>
A engine/function/adminlog.php +52-0 View file
@@ -0,0 +1,52 @@
1+<?php
2+/**
3+ * Admin action log.
4+ *
5+ * Records mutating actions taken from the admin panel - bans, skill edits,
6+ * points, settings changes, plugin lifecycle, etc - into `znote_admin_log`.
7+ * Admin modules call acp_log() right after a successful write; the table is
8+ * migrated separately (SQL/migrations/2.0.0_admin_log.sql), so acp_log()
9+ * silently no-ops on databases that have not run it yet, matching the
10+ * fallback pattern used by theme_menu_items() and friends.
11+ */
12+
13+function acp_log_table_exists(): bool {
14+ return znote_table_exists('znote_admin_log');
15+}
16+
17+/**
18+ * @param string $action Dotted code, e.g. "player.ban" - see acp_log_action_label().
19+ * @param string $target What the action was applied to, e.g. a character name.
20+ * @param array $details Small set of extra facts (old/new values, reason...),
21+ * stored as JSON and shown expanded in the log viewer.
22+ */
23+function acp_log(string $action, string $target = '', array $details = array()): bool {
24+ if (!acp_log_table_exists()) {
25+ return false;
26+ }
27+
28+ $adminId = isset($GLOBALS['session_user_id']) ? (int)$GLOBALS['session_user_id'] : 0;
29+ $adminName = isset($GLOBALS['user_data']['name']) ? (string)$GLOBALS['user_data']['name'] : '';
30+
31+ $detailsJson = '';
32+ if ($details) {
33+ $encoded = json_encode($details, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
34+ if ($encoded !== false) {
35+ $detailsJson = $encoded;
36+ }
37+ }
38+
39+ return db()->execute(
40+ "INSERT INTO `znote_admin_log` (`admin_id`, `admin_name`, `action`, `target`, `details`, `ip`, `created`)
41+ VALUES (?, ?, ?, ?, ?, ?, ?);",
42+ [
43+ $adminId,
44+ $adminName,
45+ substr($action, 0, 64),
46+ substr($target, 0, 191),
47+ $detailsJson,
48+ getIP(),
49+ time(),
50+ ]
51+ );
52+}
A engine/function/backups.php +185-0 View file
@@ -0,0 +1,185 @@
1+<?php
2+
3+const ZNOTE_BACKUP_DIR = 'engine/backups';
4+const ZNOTE_BACKUP_ROWS_PER_CHUNK = 500;
5+
6+function znote_backups_root(): string {
7+ return dirname(__DIR__, 2) . '/' . ZNOTE_BACKUP_DIR;
8+}
9+
10+function znote_backups_prepare_dir(): bool {
11+ $dir = znote_backups_root();
12+ if (!is_dir($dir) && !@mkdir($dir, 0750, true) && !is_dir($dir)) {
13+ return false;
14+ }
15+
16+ $deny = $dir . '/.htaccess';
17+ if (!is_file($deny)) {
18+ @file_put_contents($deny, "Require all denied\nDeny from all\n");
19+ }
20+
21+ return is_writable($dir);
22+}
23+
24+/** Existing backups, newest first. */
25+function znote_backups_list(): array {
26+ $dir = znote_backups_root();
27+ if (!is_dir($dir)) {
28+ return array();
29+ }
30+
31+ $out = array();
32+ foreach (glob($dir . '/*.sql.gz') ?: array() as $file) {
33+ $out[] = array(
34+ 'name' => basename($file),
35+ 'size' => (int)filesize($file),
36+ 'time' => (int)filemtime($file),
37+ );
38+ }
39+
40+ usort($out, static fn(array $a, array $b): int => $b['time'] <=> $a['time']);
41+
42+ return $out;
43+}
44+
45+function znote_backups_safe_name(string $name): string {
46+ $name = basename($name);
47+ return preg_match('/^znotex-backup-\d{8}-\d{6}\.sql\.gz$/', $name) ? $name : '';
48+}
49+
50+function znote_backups_path(string $name): ?string {
51+ $safe = znote_backups_safe_name($name);
52+ if ($safe === '') {
53+ return null;
54+ }
55+
56+ $path = znote_backups_root() . '/' . $safe;
57+ return is_file($path) ? $path : null;
58+}
59+
60+function znote_backups_delete(string $name): bool {
61+ $path = znote_backups_path($name);
62+ return $path !== null && @unlink($path);
63+}
64+
65+function znote_backups_prune(int $keep): int {
66+ if ($keep <= 0) {
67+ return 0;
68+ }
69+
70+ $all = znote_backups_list();
71+ $extra = array_slice($all, $keep);
72+ $removed = 0;
73+
74+ foreach ($extra as $backup) {
75+ if (znote_backups_delete($backup['name'])) {
76+ $removed++;
77+ }
78+ }
79+
80+ return $removed;
81+}
82+
83+function znote_backups_sql_value(mysqli $link, $value): string {
84+ if ($value === null) {
85+ return 'NULL';
86+ }
87+
88+ return "'" . $link->real_escape_string((string)$value) . "'";
89+}
90+
91+function znote_backups_write(mysqli $link, $handle): string {
92+ fwrite($handle, "-- ZnoteX backup - " . gmdate('Y-m-d H:i:s') . " UTC\n");
93+ fwrite($handle, "SET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS=0;\n\n");
94+
95+ $tables = array();
96+ $result = $link->query('SHOW TABLES;');
97+ if ($result === false) {
98+ return 'Could not list tables: ' . $link->error;
99+ }
100+ while ($row = $result->fetch_array(MYSQLI_NUM)) {
101+ $tables[] = $row[0];
102+ }
103+
104+ foreach ($tables as $table) {
105+ $escapedTable = '`' . str_replace('`', '``', $table) . '`';
106+
107+ $createResult = $link->query('SHOW CREATE TABLE ' . $escapedTable . ';');
108+ if ($createResult === false) {
109+ return 'Could not read structure of ' . $table . ': ' . $link->error;
110+ }
111+ $createRow = $createResult->fetch_assoc();
112+ $createSql = $createRow['Create Table'] ?? null;
113+ if ($createSql === null) {
114+ continue;
115+ }
116+
117+ fwrite($handle, "\n-- ----------------------------\n-- Table: {$table}\n-- ----------------------------\n");
118+ fwrite($handle, "DROP TABLE IF EXISTS {$escapedTable};\n{$createSql};\n\n");
119+
120+ $countResult = $link->query('SELECT COUNT(*) AS c FROM ' . $escapedTable . ';');
121+ $total = $countResult !== false ? (int)($countResult->fetch_assoc()['c'] ?? 0) : 0;
122+ if ($total === 0) {
123+ continue;
124+ }
125+
126+ for ($offset = 0; $offset < $total; $offset += ZNOTE_BACKUP_ROWS_PER_CHUNK) {
127+ $dataResult = $link->query('SELECT * FROM ' . $escapedTable . ' LIMIT ' . ZNOTE_BACKUP_ROWS_PER_CHUNK . ' OFFSET ' . $offset . ';');
128+ if ($dataResult === false) {
129+ return 'Could not read data from ' . $table . ': ' . $link->error;
130+ }
131+
132+ $columns = null;
133+ $rowsSql = array();
134+ while ($row = $dataResult->fetch_assoc()) {
135+ if ($columns === null) {
136+ $columns = '`' . implode('`,`', array_map(static fn($c) => str_replace('`', '``', $c), array_keys($row))) . '`';
137+ }
138+ $values = array_map(static fn($v) => znote_backups_sql_value($link, $v), array_values($row));
139+ $rowsSql[] = '(' . implode(',', $values) . ')';
140+ }
141+
142+ if ($rowsSql) {
143+ fwrite($handle, "INSERT INTO {$escapedTable} ({$columns}) VALUES\n" . implode(",\n", $rowsSql) . ";\n");
144+ }
145+ }
146+ }
147+
148+ fwrite($handle, "\nSET FOREIGN_KEY_CHECKS=1;\n");
149+
150+ return '';
151+}
152+
153+function znote_backups_create(): array {
154+ if (!znote_backups_prepare_dir()) {
155+ return array(false, 'Could not create/write to ' . ZNOTE_BACKUP_DIR . '/.');
156+ }
157+
158+ if (!class_exists('mysqli') || !function_exists('gzopen')) {
159+ return array(false, 'PHP needs the mysqli and zlib extensions for backups.');
160+ }
161+
162+ $name = 'znotex-backup-' . date('Ymd-His') . '.sql.gz';
163+ $path = znote_backups_root() . '/' . $name;
164+
165+ $handle = @gzopen($path, 'wb9');
166+ if ($handle === false) {
167+ return array(false, 'Could not open ' . $name . ' for writing.');
168+ }
169+
170+ $link = db()->connection();
171+ $error = znote_backups_write($link, $handle);
172+ gzclose($handle);
173+
174+ if ($error !== '') {
175+ @unlink($path);
176+ return array(false, $error);
177+ }
178+
179+ if (!is_file($path) || filesize($path) < 1) {
180+ @unlink($path);
181+ return array(false, 'The backup file ended up empty.');
182+ }
183+
184+ return array(true, $name);
185+}
A engine/function/bbcode.php +209-0 View file
@@ -0,0 +1,209 @@
1+<?php
2+
3+function znote_bbcode_url(string $url): string {
4+ $plain = trim(html_entity_decode($url, ENT_QUOTES, 'UTF-8'));
5+
6+ if ($plain === '' || preg_match('/[\x00-\x1f\s<>"\']/', $plain)) {
7+ return '';
8+ }
9+ if (!preg_match('#^(https?://[^/]+|/(?!/))#i', $plain)) {
10+ return '';
11+ }
12+
13+ return htmlspecialchars($plain, ENT_QUOTES, 'UTF-8');
14+}
15+
16+function znote_bbcode_color(string $color): string {
17+ $plain = trim(html_entity_decode($color, ENT_QUOTES, 'UTF-8'));
18+
19+ if (preg_match('/^#[0-9a-f]{3,8}$/i', $plain)) return $plain;
20+ if (preg_match('/^[a-z]{3,20}$/i', $plain)) return strtolower($plain);
21+ if (preg_match('/^rgba?\(\s*[0-9]{1,3}\s*,\s*[0-9]{1,3}\s*,\s*[0-9]{1,3}\s*(,\s*(0|1|0?\.[0-9]+)\s*)?\)$/i', $plain)) return $plain;
22+
23+ return '';
24+}
25+
26+/**
27+ * For text stored WITHOUT HTML escaping - news and changelog, which the admin
28+ * panel writes through esc() (SQL escaping only). Escaping here means an admin
29+ * typing <script> gets text, not script, while BBCode still renders.
30+ *
31+ * Forum posts are already escaped by sanitize() on save, so they use
32+ * znote_bbcode() directly - running this on them would double-escape.
33+ */
34+function znote_bbcode_raw(?string $text): string {
35+ return znote_bbcode(htmlspecialchars((string)$text, ENT_QUOTES, 'UTF-8'));
36+}
37+
38+function znote_bbcode_count_images(?string $text): int {
39+ return preg_match_all('/\[img(?:=[^\]]*)?\]/i', (string)$text);
40+}
41+
42+/** [code]...[/code] is pulled out first (as a \x00CODE<n>\x00 placeholder) so nothing inside it is touched by any tag below - restored at the very end by znote_bbcode_restore_code_blocks(). */
43+function znote_bbcode_extract_code_blocks(string $text, array &$codes): string {
44+ return preg_replace_callback('/\[code\](.*?)\[\/code\]/is', static function ($m) use (&$codes) {
45+ $codes[] = $m[1];
46+ return "\x00CODE" . (count($codes) - 1) . "\x00";
47+ }, $text);
48+}
49+
50+function znote_bbcode_restore_code_blocks(string $text, array $codes): string {
51+ return preg_replace_callback("/\x00CODE([0-9]+)\x00/", static function ($m) use ($codes) {
52+ return '<pre class="zbb-code"><code>' . ($codes[(int)$m[1]] ?? '') . '</code></pre>';
53+ }, $text);
54+}
55+
56+/** [font]/[table] et al carry no useful markup here - dropped rather than rendered. */
57+function znote_bbcode_strip_unsupported_tags(string $text): string {
58+ $text = preg_replace('/\[font(?:=[^\]]*)?\]/i', '', $text);
59+ $text = preg_replace('/\[\/font\]/i', '', $text);
60+ $text = preg_replace('/\[\/?(?:table|tr|td|th)(?:=[^\]]*)?\]/i', '', $text);
61+ return $text;
62+}
63+
64+function znote_bbcode_apply_inline_style_tags(string $text): string {
65+ foreach (array('b' => 'strong', 'i' => 'em', 'u' => 'u', 's' => 'del') as $tag => $html) {
66+ for ($i = 0; $i < 4; $i++) {
67+ $out = preg_replace('/\[' . $tag . '\](.*?)\[\/' . $tag . '\]/is', '<' . $html . '>$1</' . $html . '>', $text);
68+ if ($out === null || $out === $text) break;
69+ $text = $out;
70+ }
71+ }
72+ return $text;
73+}
74+
75+function znote_bbcode_apply_alignment(string $text): string {
76+ foreach (array('left', 'center', 'right', 'justify') as $align) {
77+ $text = preg_replace(
78+ '/\[' . $align . '\](.*?)\[\/' . $align . '\]/is',
79+ '<div class="zbb-align" style="text-align:' . $align . '">$1</div>',
80+ $text
81+ );
82+ }
83+ return $text;
84+}
85+
86+function znote_bbcode_apply_color(string $text): string {
87+ return preg_replace_callback('/\[color=([^\]]{1,30})\](.*?)\[\/color\]/is', static function ($m) {
88+ $color = znote_bbcode_color($m[1]);
89+ return ($color === '') ? $m[2] : '<span style="color:' . $color . '">' . $m[2] . '</span>';
90+ }, $text);
91+}
92+
93+function znote_bbcode_apply_size(string $text): string {
94+ $sizes = array(1 => '0.7em', 2 => '0.85em', 3 => '1em', 4 => '1.2em', 5 => '1.5em', 6 => '2em', 7 => '2.5em');
95+ return preg_replace_callback('/\[size=([0-9]{1,2})\](.*?)\[\/size\]/is', static function ($m) use ($sizes) {
96+ $size = $sizes[(int)$m[1]] ?? null;
97+ return ($size === null) ? $m[2] : '<span style="font-size:' . $size . '">' . $m[2] . '</span>';
98+ }, $text);
99+}
100+
101+function znote_bbcode_apply_images(string $text): string {
102+ return preg_replace_callback('/\[img(?:=([0-9]{1,4})x([0-9]{1,4}))?\]([^\[]+?)\[\/img\]/is', static function ($m) {
103+ $url = znote_bbcode_url($m[3]);
104+ if ($url === '') return '';
105+
106+ $w = (int)($m[1] ?? 0);
107+ $h = (int)($m[2] ?? 0);
108+ if (($w <= 0 || $h <= 0 || $w > 4000 || $h > 4000) && preg_match('~/letters/letter_martel_[a-z]\.gif(?:[?#].*)?$~i', $url)) {
109+ $w = 48;
110+ $h = 48;
111+ }
112+ $dim = ($w > 0 && $w <= 4000 && $h > 0 && $h <= 4000) ? ' width="' . $w . '" height="' . $h . '"' : '';
113+
114+ return '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">'
115+ . '<img src="' . $url . '" alt=""' . $dim . ' class="zbb-img" style="max-width:100%;height:auto"></a>';
116+ }, $text);
117+}
118+
119+function znote_bbcode_apply_links(string $text): string {
120+ $text = preg_replace_callback('/\[(?:url|link)=([^\]]+?)\](.*?)\[\/(?:url|link)\]/is', static function ($m) {
121+ $url = znote_bbcode_url($m[1]);
122+ return ($url === '') ? $m[2] : '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $m[2] . '</a>';
123+ }, $text);
124+
125+ $text = preg_replace_callback('/\[(?:url|link)\]([^\[]+?)\[\/(?:url|link)\]/is', static function ($m) {
126+ $url = znote_bbcode_url($m[1]);
127+ return ($url === '') ? $m[1] : '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>';
128+ }, $text);
129+
130+ return $text;
131+}
132+
133+function znote_bbcode_apply_youtube(string $text): string {
134+ return preg_replace_callback('/\[youtube\]([^\[]+?)\[\/youtube\]/is', static function ($m) {
135+ $id = trim(html_entity_decode($m[1], ENT_QUOTES, 'UTF-8'));
136+ if (preg_match('#(?:youtu\.be/|v=|embed/)([A-Za-z0-9_-]{6,20})#', $id, $found)) {
137+ $id = $found[1];
138+ }
139+ if (!preg_match('/^[A-Za-z0-9_-]{6,20}$/', $id)) {
140+ return '';
141+ }
142+ return '<div class="zbb-video"><iframe src="https://www.youtube.com/embed/' . $id
143+ . '" frameborder="0" allowfullscreen></iframe></div>';
144+ }, $text);
145+}
146+
147+function znote_bbcode_apply_quote(string $text): string {
148+ return preg_replace_callback('/\[quote(?:=([^\]]{1,40}))?\](.*?)\[\/quote\]/is', static function ($m) {
149+ $who = trim($m[1] ?? '');
150+ $head = ($who !== '') ? '<cite>' . $who . ' wrote:</cite>' : '';
151+ return '<blockquote class="zbb-quote">' . $head . $m[2] . '</blockquote>';
152+ }, $text);
153+}
154+
155+/** [*]/[li] items first, then their enclosing [ul]/[list]/[ol] - each loops since tags can nest. */
156+function znote_bbcode_apply_lists(string $text): string {
157+ $text = preg_replace('/\[\*\](.*?)\[\/\*\]/is', '<li>$1</li>', $text);
158+ $text = preg_replace('/\[\*\]\s*([^\[\r\n]*)/i', '<li>$1</li>', $text);
159+
160+ for ($i = 0; $i < 8; $i++) {
161+ $out = preg_replace('/\[li\]((?:(?!\[li\]|\[\/li\]).)*)\[\/li\]/is', '<li>$1</li>', $text);
162+ if ($out === null || $out === $text) break;
163+ $text = $out;
164+ }
165+ for ($i = 0; $i < 8; $i++) {
166+ $out = preg_replace('/\[(?:ul|list)(?:=[^\]]*)?\]((?:(?!\[(?:ul|list)(?:=[^\]]*)?\]|\[\/(?:ul|list)\]).)*)\[\/(?:ul|list)\]/is', '<ul class="zbb-list">$1</ul>', $text);
167+ if ($out === null || $out === $text) break;
168+ $text = $out;
169+ }
170+ for ($i = 0; $i < 8; $i++) {
171+ $out = preg_replace('/\[ol\]((?:(?!\[ol\]|\[\/ol\]).)*)\[\/ol\]/is', '<ol class="zbb-list">$1</ol>', $text);
172+ if ($out === null || $out === $text) break;
173+ $text = $out;
174+ }
175+
176+ return $text;
177+}
178+
179+/** nl2br(), then undo it right next to a block element that already carries its own margin. */
180+function znote_bbcode_apply_linebreaks(string $text): string {
181+ $text = nl2br($text, false);
182+ $text = preg_replace('#<br>\s*(</?(?:ul|ol|li|blockquote|div|cite)[^>]*>)#i', '$1', $text);
183+ $text = preg_replace('#(</?(?:ul|ol|li|blockquote|div|cite)[^>]*>)\s*<br>#i', '$1', $text);
184+ return $text;
185+}
186+
187+function znote_bbcode(?string $text): string {
188+ $text = (string)$text;
189+ if ($text === '') {
190+ return '';
191+ }
192+
193+ $codes = array();
194+ $text = znote_bbcode_extract_code_blocks($text, $codes);
195+ $text = znote_bbcode_strip_unsupported_tags($text);
196+ $text = znote_bbcode_apply_inline_style_tags($text);
197+ $text = znote_bbcode_apply_alignment($text);
198+ $text = znote_bbcode_apply_color($text);
199+ $text = znote_bbcode_apply_size($text);
200+ $text = znote_bbcode_apply_images($text);
201+ $text = znote_bbcode_apply_links($text);
202+ $text = znote_bbcode_apply_youtube($text);
203+ $text = znote_bbcode_apply_quote($text);
204+ $text = znote_bbcode_apply_lists($text);
205+ $text = znote_bbcode_apply_linebreaks($text);
206+ $text = znote_bbcode_restore_code_blocks($text, $codes);
207+
208+ return $text;
209+}
A engine/function/cache.php +333-0 View file
@@ -0,0 +1,333 @@
1+<?php
2+
3+class Cache
4+{
5+ protected string $_file;
6+ protected string $_key;
7+ protected string $_prefix;
8+ protected int $_lifespan = 0;
9+ protected mixed $_content = null;
10+ protected bool $_memory = false;
11+ protected bool $_canMemory = false;
12+
13+ private const FORMAT = 'ZNOTEX_CACHE_V1:';
14+ public const EXT = '.cache';
15+
16+ public function __construct(string $file)
17+ {
18+ $cfg = function_exists('config')
19+ ? config('cache')
20+ : ($GLOBALS['config']['cache'] ?? array());
21+
22+ if (!is_array($cfg)) {
23+ $cfg = array();
24+ }
25+
26+ $this->_lifespan = max(0, (int)($cfg['lifespan'] ?? 60));
27+ $this->_prefix = self::normalizePrefix((string)($cfg['prefix'] ?? 'znote_'));
28+ $this->_canMemory = self::memoryAvailable();
29+ $this->_memory = !empty($cfg['memory']) && $this->_canMemory;
30+ $this->_file = $file . self::EXT;
31+ $logicalName = str_replace('\\', '/', $this->_file);
32+ $this->_key = $this->_prefix . 'cache:' . hash('sha256', $logicalName);
33+ }
34+
35+ public static function memoryAvailable(): bool
36+ {
37+ if (!function_exists('apcu_fetch') || !function_exists('apcu_store')) {
38+ return false;
39+ }
40+
41+ if (function_exists('apcu_enabled')) {
42+ return apcu_enabled();
43+ }
44+
45+ if (PHP_SAPI === 'cli' && !filter_var(ini_get('apc.enable_cli'), FILTER_VALIDATE_BOOL)) {
46+ return false;
47+ }
48+
49+ return filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOL);
50+ }
51+
52+ public static function configuredPrefix(): string
53+ {
54+ $cfg = function_exists('config')
55+ ? config('cache')
56+ : ($GLOBALS['config']['cache'] ?? array());
57+
58+ return self::normalizePrefix(is_array($cfg) ? (string)($cfg['prefix'] ?? 'znote_') : 'znote_');
59+ }
60+
61+ private static function normalizePrefix(string $prefix): string
62+ {
63+ $prefix = preg_replace('/[^a-zA-Z0-9_.:-]/', '_', trim($prefix)) ?? '';
64+ return substr($prefix !== '' ? $prefix : 'znote_', 0, 64);
65+ }
66+
67+ public function setExpiration(int $span): void
68+ {
69+ $this->_lifespan = max(0, $span);
70+ }
71+
72+ public function useMemory(bool $bool): bool
73+ {
74+ $this->_memory = $bool && $this->_canMemory;
75+ return $this->_memory;
76+ }
77+
78+ public function driver(): string
79+ {
80+ return $this->_memory ? 'apcu' : 'file';
81+ }
82+
83+ public function setContent(mixed $content): void
84+ {
85+ $this->_content = $content;
86+ }
87+
88+ public function hasExpired(): bool
89+ {
90+ if ($this->_memory) {
91+ return !apcu_exists($this->_key);
92+ }
93+
94+ if (!is_file($this->_file)) {
95+ return true;
96+ }
97+
98+ if ($this->_lifespan === 0) {
99+ return false;
100+ }
101+
102+ $modified = filemtime($this->_file);
103+ return $modified === false || time() >= $modified + $this->_lifespan;
104+ }
105+
106+ public function remainingTime(): int
107+ {
108+ if ($this->hasExpired()) {
109+ return 0;
110+ }
111+
112+ if ($this->_lifespan === 0) {
113+ return PHP_INT_MAX;
114+ }
115+
116+ if ($this->_memory) {
117+ $success = false;
118+ $payload = apcu_fetch($this->_key, $success);
119+ if (!$success) {
120+ return 0;
121+ }
122+
123+ $envelope = $this->decodeEnvelope($payload);
124+ return is_array($envelope)
125+ ? max(0, (int)$envelope['expires'] - time())
126+ : 0;
127+ }
128+
129+ $modified = filemtime($this->_file);
130+ return $modified === false ? 0 : max(0, ($modified + $this->_lifespan) - time());
131+ }
132+
133+ public function save(): bool
134+ {
135+ $payload = $this->encodeContent();
136+ if ($payload === false) {
137+ return false;
138+ }
139+
140+ if ($this->_memory) {
141+ return apcu_store($this->_key, $payload, $this->_lifespan);
142+ }
143+
144+ $directory = dirname($this->_file);
145+ if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory)) {
146+ return false;
147+ }
148+
149+ $temporary = tempnam($directory, '.znote-cache-');
150+ if ($temporary === false) {
151+ return false;
152+ }
153+
154+ $written = file_put_contents($temporary, $payload, LOCK_EX);
155+ if ($written === false) {
156+ @unlink($temporary);
157+ return false;
158+ }
159+
160+ if (@rename($temporary, $this->_file)) {
161+ clearstatcache(true, $this->_file);
162+ return true;
163+ }
164+
165+ $saved = file_put_contents($this->_file, $payload, LOCK_EX) !== false;
166+ @unlink($temporary);
167+ clearstatcache(true, $this->_file);
168+ return $saved;
169+ }
170+
171+ public function load(): mixed
172+ {
173+ if ($this->_memory) {
174+ $success = false;
175+ $payload = apcu_fetch($this->_key, $success);
176+ return $success ? $this->decodeContent($payload) : false;
177+ }
178+
179+ if (!is_file($this->_file)) {
180+ return false;
181+ }
182+
183+ $handle = @fopen($this->_file, 'rb');
184+ if ($handle === false) {
185+ return false;
186+ }
187+
188+ $payload = false;
189+ if (flock($handle, LOCK_SH)) {
190+ $payload = stream_get_contents($handle);
191+ flock($handle, LOCK_UN);
192+ }
193+ fclose($handle);
194+
195+ return $payload === false || $payload === '' ? false : $this->decodeContent($payload);
196+ }
197+
198+ public function delete(): bool
199+ {
200+ if ($this->_memory) {
201+ return !apcu_exists($this->_key) || apcu_delete($this->_key);
202+ }
203+
204+ if (!is_file($this->_file)) {
205+ return true;
206+ }
207+
208+ $deleted = @unlink($this->_file);
209+ clearstatcache(true, $this->_file);
210+ return $deleted;
211+ }
212+
213+ private function encodeContent(): string|false
214+ {
215+ $envelope = array(
216+ 'version' => 1,
217+ 'expires' => $this->_lifespan === 0 ? 0 : time() + $this->_lifespan,
218+ 'value' => $this->_content,
219+ );
220+
221+ try {
222+ return self::FORMAT . base64_encode(serialize($envelope));
223+ } catch (Throwable $error) {
224+ return false;
225+ }
226+ }
227+
228+ private function decodeEnvelope(mixed $payload): array|false
229+ {
230+ if (!is_string($payload) || !str_starts_with($payload, self::FORMAT)) {
231+ return false;
232+ }
233+
234+ $decoded = base64_decode(substr($payload, strlen(self::FORMAT)), true);
235+ if ($decoded === false) {
236+ return false;
237+ }
238+
239+ try {
240+ $envelope = @unserialize($decoded, array('allowed_classes' => false));
241+ } catch (Throwable $error) {
242+ return false;
243+ }
244+
245+ return is_array($envelope)
246+ && ($envelope['version'] ?? null) === 1
247+ && array_key_exists('value', $envelope)
248+ ? $envelope
249+ : false;
250+ }
251+
252+ private function decodeContent(mixed $payload): mixed
253+ {
254+ $envelope = $this->decodeEnvelope($payload);
255+ if ($envelope !== false) {
256+ return $envelope['value'];
257+ }
258+
259+ if (!is_string($payload) || $payload === '') {
260+ return $payload;
261+ }
262+
263+ $json = json_decode($payload, true);
264+ return json_last_error() === JSON_ERROR_NONE ? $json : $payload;
265+ }
266+}
267+
268+function znote_cache_stats(): array
269+{
270+ $files = 0;
271+ $bytes = 0;
272+ $root = realpath('engine/cache');
273+
274+ if ($root !== false && is_dir($root)) {
275+ $iterator = new RecursiveIteratorIterator(
276+ new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)
277+ );
278+
279+ foreach ($iterator as $entry) {
280+ if ($entry->isFile() && str_ends_with($entry->getFilename(), Cache::EXT)) {
281+ $files++;
282+ $bytes += $entry->getSize();
283+ }
284+ }
285+ }
286+
287+ $cfg = function_exists('config')
288+ ? config('cache')
289+ : ($GLOBALS['config']['cache'] ?? array());
290+ $requestedMemory = is_array($cfg) && !empty($cfg['memory']);
291+
292+ return array(
293+ 'driver' => $requestedMemory && Cache::memoryAvailable() ? 'APCu' : 'Files',
294+ 'requested_memory' => $requestedMemory,
295+ 'apcu_available' => Cache::memoryAvailable(),
296+ 'files' => $files,
297+ 'bytes' => $bytes,
298+ 'prefix' => Cache::configuredPrefix(),
299+ );
300+}
301+
302+function znote_cache_flush(): int
303+{
304+ $removed = 0;
305+ $root = realpath('engine/cache');
306+
307+ if ($root !== false && is_dir($root)) {
308+ $iterator = new RecursiveIteratorIterator(
309+ new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
310+ RecursiveIteratorIterator::CHILD_FIRST
311+ );
312+
313+ foreach ($iterator as $entry) {
314+ if ($entry->isFile() && str_ends_with($entry->getFilename(), Cache::EXT) && @unlink($entry->getPathname())) {
315+ $removed++;
316+ }
317+ }
318+ }
319+
320+ if (Cache::memoryAvailable() && function_exists('apcu_cache_info')) {
321+ $prefix = Cache::configuredPrefix() . 'cache:';
322+ $info = apcu_cache_info(false);
323+ foreach (($info['cache_list'] ?? array()) as $item) {
324+ $key = (string)($item['info'] ?? '');
325+ $legacyKey = str_replace('\\', '/', $key);
326+ if ((str_starts_with($key, $prefix) || str_starts_with($legacyKey, 'engine/cache/')) && apcu_delete($key)) {
327+ $removed++;
328+ }
329+ }
330+ }
331+
332+ return $removed;
333+}
A engine/function/downloads.php +117-0 View file
@@ -0,0 +1,117 @@
1+<?php
2+
3+/**
4+ * config.php's old single client_download/client_download_linux keys, in the
5+ * same shape as a downloads.entries row - so they can be merged/normalized
6+ * through the exact same code path as any custom entry an admin adds.
7+ */
8+function znote_download_legacy_entries(array $config): array {
9+ $clientVersion = isset($config['client']) ? ((int)$config['client'] / 100) : '';
10+
11+ return array(
12+ 'windows_client' => array(
13+ 'label' => t('downloads.win', ['version' => $clientVersion]),
14+ 'url' => (string)($config['client_download'] ?? ''),
15+ 'section' => 'official',
16+ 'image' => '',
17+ 'description' => '',
18+ 'enabled' => !empty($config['client_download']),
19+ ),
20+ 'linux_client' => array(
21+ 'label' => t('downloads.linux', ['version' => $clientVersion]),
22+ 'url' => (string)($config['client_download_linux'] ?? ''),
23+ 'section' => 'unsupported',
24+ 'image' => '',
25+ 'description' => '',
26+ 'enabled' => !empty($config['client_download_linux']),
27+ ),
28+ );
29+}
30+
31+/** One config.php downloads.entries row, normalized - or null if it has no usable key. */
32+function znote_download_normalize_entry($entry, array $legacy): ?array {
33+ if (!is_array($entry)) {
34+ return null;
35+ }
36+
37+ $key = preg_replace('/[^a-z0-9_]/', '', strtolower((string)($entry['key'] ?? '')));
38+ if ($key === '') {
39+ return null;
40+ }
41+
42+ $item = array(
43+ 'key' => $key,
44+ 'label' => trim((string)($entry['label'] ?? '')),
45+ 'url' => trim((string)($entry['url'] ?? '')),
46+ 'section' => trim((string)($entry['section'] ?? 'custom')),
47+ 'image' => trim((string)($entry['image'] ?? '')),
48+ 'description' => trim((string)($entry['description'] ?? '')),
49+ 'enabled' => !empty($entry['enabled']) && (string)$entry['enabled'] !== '0',
50+ );
51+
52+ if (isset($legacy[$key])) {
53+ $item = array_merge($item, $legacy[$key]);
54+ }
55+ if ($item['label'] === '') {
56+ $item['label'] = ucwords(str_replace('_', ' ', $key));
57+ }
58+ if ($item['section'] === '') {
59+ $item['section'] = 'custom';
60+ }
61+
62+ return $item;
63+}
64+
65+/** Windows/Linux client links an admin never added to downloads.entries at all - so they still show up if config.php sets a URL for them. */
66+function znote_download_append_missing_legacy(array $normalized, array $legacy, bool $includeDisabled): array {
67+ foreach ($legacy as $key => $entry) {
68+ $exists = false;
69+ foreach ($normalized as $item) {
70+ if ($item['key'] === $key) {
71+ $exists = true;
72+ break;
73+ }
74+ }
75+ if (!$exists && ($includeDisabled || ($entry['enabled'] && $entry['url'] !== ''))) {
76+ $normalized[] = array_merge(array('key' => $key), $entry);
77+ }
78+ }
79+
80+ return $normalized;
81+}
82+
83+function znote_download_entries(bool $includeDisabled = false): array {
84+ global $config;
85+
86+ $entries = $config['downloads']['entries'] ?? array();
87+ if (!is_array($entries)) {
88+ $entries = array();
89+ }
90+
91+ $legacy = znote_download_legacy_entries($config);
92+
93+ $normalized = array();
94+ foreach ($entries as $entry) {
95+ $item = znote_download_normalize_entry($entry, $legacy);
96+ if ($item === null) {
97+ continue;
98+ }
99+ if ($includeDisabled || ($item['enabled'] && $item['url'] !== '')) {
100+ $normalized[] = $item;
101+ }
102+ }
103+
104+ return znote_download_append_missing_legacy($normalized, $legacy, $includeDisabled);
105+}
106+
107+function znote_download_entries_by_section(bool $includeDisabled = false): array {
108+ $sections = array();
109+ foreach (znote_download_entries($includeDisabled) as $entry) {
110+ $section = (string)$entry['section'];
111+ if (!isset($sections[$section])) {
112+ $sections[$section] = array();
113+ }
114+ $sections[$section][] = $entry;
115+ }
116+ return $sections;
117+}
A engine/function/extensions.php +378-0 View file
@@ -0,0 +1,378 @@
1+<?php
2+
3+const ZNOTE_EXTENSION_API_VERSION = '1.0.0';
4+
5+function znote_extension_version_matches(string $version, string $constraint): bool
6+{
7+ $version = ltrim(trim($version), 'vV');
8+ $constraint = trim($constraint);
9+
10+ if ($version === '' || $constraint === '' || $constraint === '*') {
11+ return true;
12+ }
13+
14+ foreach (preg_split('/\s*\|\|\s*/', $constraint) ?: array() as $alternative) {
15+ $alternative = preg_replace('/(>=|<=|>|<|==|=|!=)\s+/', '$1', trim($alternative)) ?? '';
16+ $parts = preg_split('/[\s,]+/', $alternative, -1, PREG_SPLIT_NO_EMPTY) ?: array();
17+ $matches = $parts !== array();
18+
19+ foreach ($parts as $part) {
20+ if (!znote_extension_version_part_matches($version, $part)) {
21+ $matches = false;
22+ break;
23+ }
24+ }
25+
26+ if ($matches) {
27+ return true;
28+ }
29+ }
30+
31+ return false;
32+}
33+
34+function znote_extension_version_part_matches(string $version, string $constraint): bool
35+{
36+ if ($constraint === '' || $constraint === '*') {
37+ return true;
38+ }
39+
40+ if ($constraint[0] === '^') {
41+ return znote_extension_version_caret_matches($version, $constraint);
42+ }
43+
44+ if ($constraint[0] === '~') {
45+ return znote_extension_version_tilde_matches($version, $constraint);
46+ }
47+
48+ if (str_contains($constraint, '*') || str_contains(strtolower($constraint), 'x')) {
49+ return znote_extension_version_wildcard_matches($version, $constraint);
50+ }
51+
52+ return znote_extension_version_operator_matches($version, $constraint);
53+}
54+
55+function znote_extension_version_caret_matches(string $version, string $constraint): bool
56+{
57+ $minimum = substr($constraint, 1);
58+ $segments = array_map('intval', explode('.', $minimum));
59+ $major = $segments[0] ?? 0;
60+ $minor = $segments[1] ?? 0;
61+ $patch = $segments[2] ?? 0;
62+ $maximum = $major > 0
63+ ? ($major + 1) . '.0.0'
64+ : ($minor > 0 ? '0.' . ($minor + 1) . '.0' : '0.0.' . ($patch + 1));
65+
66+ return version_compare($version, $minimum, '>=') && version_compare($version, $maximum, '<');
67+}
68+
69+function znote_extension_version_tilde_matches(string $version, string $constraint): bool
70+{
71+ $minimum = substr($constraint, 1);
72+ $rawSegments = explode('.', $minimum);
73+ $segments = array_map('intval', $rawSegments);
74+ $major = $segments[0] ?? 0;
75+ $minor = $segments[1] ?? 0;
76+ $maximum = count($rawSegments) >= 3
77+ ? $major . '.' . ($minor + 1) . '.0'
78+ : ($major + 1) . '.0.0';
79+
80+ return version_compare($version, $minimum, '>=') && version_compare($version, $maximum, '<');
81+}
82+
83+function znote_extension_version_wildcard_matches(string $version, string $constraint): bool
84+{
85+ $segments = explode('.', str_replace(array('X', 'x'), '*', $constraint));
86+ $fixed = array();
87+
88+ foreach ($segments as $segment) {
89+ if ($segment === '*') {
90+ break;
91+ }
92+ $fixed[] = max(0, (int)$segment);
93+ }
94+
95+ if ($fixed === array()) {
96+ return true;
97+ }
98+
99+ $minimumParts = array_pad($fixed, 3, 0);
100+ $maximumParts = $minimumParts;
101+ $index = count($fixed) - 1;
102+ $maximumParts[$index]++;
103+ for ($i = $index + 1; $i < 3; $i++) {
104+ $maximumParts[$i] = 0;
105+ }
106+
107+ $minimum = implode('.', $minimumParts);
108+ $maximum = implode('.', $maximumParts);
109+ return version_compare($version, $minimum, '>=') && version_compare($version, $maximum, '<');
110+}
111+
112+function znote_extension_version_operator_matches(string $version, string $constraint): bool
113+{
114+ if (!preg_match('/^(>=|<=|>|<|==|=|!=)?(.+)$/', $constraint, $match)) {
115+ return false;
116+ }
117+
118+ $operator = $match[1] !== '' ? $match[1] : '=';
119+ return version_compare($version, ltrim(trim($match[2]), 'vV'), $operator);
120+}
121+
122+function znote_extension_requirements(array $manifest): array
123+{
124+ $requires = $manifest['requires'] ?? array();
125+
126+ if (is_string($requires)) {
127+ $requires = trim($requires);
128+ return $requires === '' ? array() : array('znotex' => '>=' . ltrim($requires, 'vV'));
129+ }
130+
131+ if (!is_array($requires)) {
132+ return array();
133+ }
134+
135+ if (isset($requires['znote']) && !isset($requires['znotex'])) {
136+ $requires['znotex'] = $requires['znote'];
137+ }
138+
139+ return $requires;
140+}
141+
142+function znote_extension_compatibility(array $manifest): array
143+{
144+ $requires = znote_extension_requirements($manifest);
145+ $versions = array(
146+ 'znotex' => (string)($GLOBALS['version'] ?? '0.0.0'),
147+ 'php' => PHP_VERSION,
148+ 'api' => ZNOTE_EXTENSION_API_VERSION,
149+ );
150+ $errors = array();
151+
152+ foreach ($versions as $component => $current) {
153+ $declared = $requires[$component] ?? '';
154+ $constraint = is_string($declared) || is_numeric($declared) ? trim((string)$declared) : '';
155+ if ($declared !== '' && $constraint === '') {
156+ $errors[] = 'Invalid ' . $component . ' version requirement.';
157+ continue;
158+ }
159+ if ($constraint !== '' && !znote_extension_version_matches($current, $constraint)) {
160+ $errors[] = 'Requires ' . ($component === 'znotex' ? 'ZnoteX' : strtoupper($component))
161+ . ' ' . $constraint . '; running ' . $current . '.';
162+ }
163+ }
164+
165+ $extensions = $requires['extensions'] ?? array();
166+ if (is_string($extensions)) {
167+ $extensions = preg_split('/[\s,]+/', $extensions, -1, PREG_SPLIT_NO_EMPTY) ?: array();
168+ }
169+ if (is_array($extensions)) {
170+ foreach ($extensions as $extension) {
171+ $extension = strtolower(trim((string)$extension));
172+ if ($extension !== '' && !extension_loaded($extension)) {
173+ $errors[] = 'Requires PHP extension ' . $extension . '.';
174+ }
175+ }
176+ }
177+
178+ return array(
179+ 'compatible' => $errors === array(),
180+ 'errors' => $errors,
181+ 'requires' => $requires,
182+ 'versions' => $versions,
183+ );
184+}
185+
186+function znote_extension_relative_path(string $path): string
187+{
188+ $path = trim(str_replace('\\', '/', trim($path)), '/');
189+ $segments = array_values(array_filter(explode('/', $path), static fn(string $part): bool => $part !== ''));
190+
191+ if ($path === ''
192+ || preg_match('/[\x00-\x1F\x7F?#%<>"\x3A;]/u', $path)
193+ || in_array('..', $segments, true)
194+ || in_array('.', $segments, true)
195+ ) {
196+ return '';
197+ }
198+
199+ return implode('/', $segments);
200+}
201+
202+function znote_extension_url_path(string $path): string
203+{
204+ $path = znote_extension_relative_path($path);
205+ return $path === '' ? '' : implode('/', array_map('rawurlencode', explode('/', $path)));
206+}
207+
208+final class ZnoteExtensionApi
209+{
210+ private string $kind;
211+ private string $name;
212+
213+ private function __construct(string $kind, string $name)
214+ {
215+ $this->kind = $kind;
216+ $this->name = $name;
217+ }
218+
219+ public static function plugin(string $name): self
220+ {
221+ $name = function_exists('znote_plugin_sanitize') ? znote_plugin_sanitize($name) : '';
222+ if ($name === '') {
223+ throw new InvalidArgumentException('Invalid plugin name.');
224+ }
225+
226+ return new self('plugin', $name);
227+ }
228+
229+ public static function theme(string $name): self
230+ {
231+ $name = function_exists('theme_sanitize') ? theme_sanitize($name) : '';
232+ if ($name === '') {
233+ throw new InvalidArgumentException('Invalid theme name.');
234+ }
235+
236+ return new self('theme', $name);
237+ }
238+
239+ public function apiVersion(): string
240+ {
241+ return ZNOTE_EXTENSION_API_VERSION;
242+ }
243+
244+ public function znoteVersion(): string
245+ {
246+ return (string)($GLOBALS['version'] ?? '0.0.0');
247+ }
248+
249+ public function kind(): string
250+ {
251+ return $this->kind;
252+ }
253+
254+ public function name(): string
255+ {
256+ return $this->name;
257+ }
258+
259+ public function config(string $path = '', mixed $default = null): mixed
260+ {
261+ $value = $GLOBALS['config'] ?? array();
262+ if ($path === '') {
263+ return $value;
264+ }
265+
266+ foreach (explode('.', $path) as $segment) {
267+ if (!is_array($value) || !array_key_exists($segment, $value)) {
268+ return $default;
269+ }
270+ $value = $value[$segment];
271+ }
272+
273+ return $value;
274+ }
275+
276+ public function setting(string $key, ?string $default = null): ?string
277+ {
278+ $key = $this->settingKey($key);
279+ return function_exists('setting') ? setting($key, $default) : $default;
280+ }
281+
282+ public function setSetting(string $key, string $value): bool
283+ {
284+ return function_exists('setting_set') && setting_set($this->settingKey($key), $value);
285+ }
286+
287+ public function database(): mixed
288+ {
289+ return function_exists('db') ? db() : null;
290+ }
291+
292+ public function cache(string $key, ?int $lifespan = null, ?bool $memory = null): Cache
293+ {
294+ $key = strtolower(trim($key));
295+ if (!preg_match('/^[a-z0-9_.-]{1,100}$/', $key)) {
296+ throw new InvalidArgumentException('Invalid cache key.');
297+ }
298+
299+ $cache = new Cache('engine/cache/extensions/' . $this->kind . '/' . $this->name . '/' . $key);
300+ if ($lifespan !== null) {
301+ $cache->setExpiration($lifespan);
302+ }
303+ if ($memory !== null) {
304+ $cache->useMemory($memory);
305+ }
306+
307+ return $cache;
308+ }
309+
310+ public function on(string $hook, callable $callback, int $priority = 10): void
311+ {
312+ znote_hook_register($hook, $callback, $priority);
313+ }
314+
315+ public function dispatch(string $hook, array $data = array()): void
316+ {
317+ znote_hook($hook, $data);
318+ }
319+
320+ public function collect(string $hook, array $data = array()): string
321+ {
322+ return znote_hook_collect($hook, $data);
323+ }
324+
325+ public function filter(string $hook, mixed $value, array $data = array()): mixed
326+ {
327+ return znote_hook_filter($hook, $value, $data);
328+ }
329+
330+ public function allows(string $hook, array $data = array()): bool
331+ {
332+ return znote_hook_allows($hook, $data);
333+ }
334+
335+ public function url(string $page = ''): string
336+ {
337+ return $this->kind === 'plugin'
338+ ? znote_plugin_url($this->name, $page)
339+ : theme_url($this->name);
340+ }
341+
342+ public function asset(string $file): string
343+ {
344+ $file = znote_extension_relative_path($file);
345+ if ($file === '') {
346+ return '';
347+ }
348+
349+ return $this->kind === 'plugin'
350+ ? znote_plugin_asset($this->name, $file)
351+ : 'layouts/' . $this->name . '/assets/' . znote_extension_url_path($file);
352+ }
353+
354+ public function themeOption(string $key, string $default = ''): string
355+ {
356+ return function_exists('theme_option') ? theme_option($key, $default, $this->kind === 'theme' ? $this->name : null) : $default;
357+ }
358+
359+ private function settingKey(string $key): string
360+ {
361+ $key = strtolower(trim($key));
362+ if (!preg_match('/^[a-z0-9_.-]{1,100}$/', $key)) {
363+ throw new InvalidArgumentException('Invalid setting key.');
364+ }
365+
366+ return $this->kind . ':' . $this->name . ':setting:' . $key;
367+ }
368+}
369+
370+function znote_plugin_api(string $plugin): ZnoteExtensionApi
371+{
372+ return ZnoteExtensionApi::plugin($plugin);
373+}
374+
375+function znote_theme_api(?string $theme = null): ZnoteExtensionApi
376+{
377+ return ZnoteExtensionApi::theme($theme ?? theme_active());
378+}
A engine/function/health.php +36-0 View file
@@ -0,0 +1,36 @@
1+<?php
2+
3+function znote_health_issues(): array
4+{
5+ $issues = array();
6+
7+ if (!extension_loaded('mysqli')) {
8+ $issues[] = array('label' => 'PHP extension', 'detail' => 'mysqli is not loaded.');
9+ }
10+
11+ $root = dirname(__DIR__, 2);
12+ $free = @disk_free_space($root);
13+ $total = @disk_total_space($root);
14+ if ($free !== false && $total !== false && $total > 0) {
15+ $percentFree = ($free / $total) * 100;
16+ if ($percentFree < 5 || $free < 524288000) {
17+ $issues[] = array(
18+ 'label' => 'Disk space',
19+ 'detail' => number_format($free / 1048576, 0) . ' MB free (' . number_format($percentFree, 1) . '%).',
20+ );
21+ }
22+ }
23+
24+ if (function_exists('znote_migrations_status')) {
25+ foreach (znote_migrations_status() as $name => $migration) {
26+ if (($migration['state'] ?? '') === 'changed') {
27+ $issues[] = array(
28+ 'label' => 'Migration changed',
29+ 'detail' => (string) $name . ' was applied but the file no longer matches what ran.',
30+ );
31+ }
32+ }
33+ }
34+
35+ return $issues;
36+}
A engine/function/itemparser/itemlistparser.php +49-0 View file
@@ -0,0 +1,49 @@
1+<?php
2+
3+function getItemList(): array {
4+ return parseItems();
5+}
6+
7+function getItemById(int $id): string|false {
8+ static $items = null;
9+
10+ if ($items === null) {
11+ $items = parseItems();
12+ }
13+
14+ return $items[$id] ?? false;
15+}
16+
17+function parseItems(): array {
18+ // ZnoteAAC compatible
19+ $serverPath = function_exists('Config')
20+ ? Config('server_path')
21+ : ($GLOBALS['config']['server_path'] ?? null);
22+
23+ if (!$serverPath) {
24+ return [];
25+ }
26+
27+ $file = $serverPath . '/data/items/items.xml';
28+
29+ if (!file_exists($file)) {
30+ return [];
31+ }
32+
33+ libxml_use_internal_errors(true);
34+ $xml = simplexml_load_file($file);
35+
36+ if ($xml === false) {
37+ return [];
38+ }
39+
40+ $itemList = [];
41+
42+ foreach ($xml->children() as $item) {
43+ if (isset($item['id'], $item['name'])) {
44+ $itemList[(int)$item['id']] = (string)$item['name'];
45+ }
46+ }
47+
48+ return $itemList;
49+}
A engine/function/landing.php +132-0 View file
@@ -0,0 +1,132 @@
1+<?php
2+
3+const ZNOTE_LANDING_DIR = 'landing';
4+
5+function landing_root(): string
6+{
7+ return dirname(__DIR__, 2) . '/' . ZNOTE_LANDING_DIR;
8+}
9+
10+function landing_enabled(): bool
11+{
12+ return function_exists('setting') && (string)setting('landing.enabled', '') === '1';
13+}
14+
15+/** The file inside landing/ that is served. */
16+function landing_file(): string
17+{
18+ $file = function_exists('setting') ? (string)setting('landing.file', '') : '';
19+ $file = trim($file);
20+
21+ return ($file !== '' && landing_is_valid_file($file)) ? $file : 'index.html';
22+}
23+
24+/** One path segment, .html or .php, never escaping landing/. */
25+function landing_is_valid_file(string $file): bool
26+{
27+ if (strpos($file, '/') !== false || strpos($file, '\\') !== false || strpos($file, '..') !== false) {
28+ return false;
29+ }
30+
31+ return (bool)preg_match('/^[A-Za-z0-9._-]{1,64}\.(html?|php)$/', $file);
32+}
33+
34+/** Every page landing/ offers, for the admin picker. */
35+function landing_available_files(): array
36+{
37+ $files = array();
38+
39+ foreach (glob(landing_root() . '/*.{html,htm,php}', GLOB_BRACE) ?: array() as $path) {
40+ $name = basename($path);
41+ if (landing_is_valid_file($name)) {
42+ $files[] = $name;
43+ }
44+ }
45+
46+ sort($files);
47+
48+ return $files;
49+}
50+
51+function landing_path(): string
52+{
53+ return landing_root() . '/' . landing_file();
54+}
55+
56+function landing_ready(): bool
57+{
58+ return landing_enabled() && is_file(landing_path());
59+}
60+
61+/**
62+ * The landing folder's URL, worked out from the running script so this keeps
63+ * working when ZnoteX lives in a subdirectory.
64+ */
65+function landing_base_url(): string
66+{
67+ $dir = str_replace('\\', '/', dirname((string)($_SERVER['SCRIPT_NAME'] ?? '/')));
68+ $dir = ($dir === '/' || $dir === '.') ? '' : rtrim($dir, '/');
69+
70+ return $dir . '/' . ZNOTE_LANDING_DIR . '/';
71+}
72+
73+/**
74+ * A visitor who asked for the real site, remembered for the rest of the visit
75+ * so every link back to the front page does not drop them on the landing page
76+ * again.
77+ */
78+function landing_bypassed(): bool
79+{
80+ if (isset($_GET['site'])) {
81+ $_SESSION['landing_bypass'] = true;
82+ return true;
83+ }
84+
85+ return !empty($_SESSION['landing_bypass']);
86+}
87+
88+/**
89+ * Serve the landing page in place of the front page, and stop. Does nothing
90+ * unless the feature is on, the file is there, and the visitor has not asked
91+ * for the real site with ?site=1.
92+ */
93+function landing_serve(): void
94+{
95+ if (!landing_ready() || landing_bypassed()) {
96+ return;
97+ }
98+
99+ $path = landing_path();
100+
101+ if (strtolower((string)pathinfo($path, PATHINFO_EXTENSION)) === 'php') {
102+ ob_start();
103+ include $path;
104+ $html = (string)ob_get_clean();
105+ } else {
106+ $html = (string)file_get_contents($path);
107+ }
108+
109+ echo landing_apply_base($html);
110+ exit;
111+}
112+
113+/**
114+ * The page is served from the site root, so its own relative css/, img/ and js/
115+ * would resolve one folder too high. A <base> fixes every one of them at once.
116+ * Links that must leave the landing page use a leading slash and are unaffected.
117+ */
118+function landing_apply_base(string $html): string
119+{
120+ if (stripos($html, '<base ') !== false) {
121+ return $html;
122+ }
123+
124+ $base = '<base href="' . htmlspecialchars(landing_base_url(), ENT_QUOTES, 'UTF-8') . '">';
125+
126+ if (preg_match('~<head\b[^>]*>~i', $html, $m, PREG_OFFSET_CAPTURE)) {
127+ $at = $m[0][1] + strlen($m[0][0]);
128+ return substr($html, 0, $at) . "\n\t" . $base . substr($html, $at);
129+ }
130+
131+ return $base . $html;
132+}
Top