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 .codefactor.yml +18-0 View file
@@ -0,0 +1,18 @@
1+# CodeFactor configuration.
2+# Keep analysis on hand-written application code; skip vendored, generated,
3+# minified and machine-converted files that we do not maintain by hand.
4+
5+exclude_paths:
6+ - "assets/sceditor/**"
7+ - "assets/fontawesome/**"
8+ - "**/*.min.js"
9+ - "**/*.min.css"
10+ - "**/*.map"
11+ - "layouts/*/_locale_update/**"
12+ - "layouts/*/assets/**"
13+ - "engine/function/itemparser/**"
14+ - "engine/cert/**"
15+ - "engine/cache/**"
16+ - "TO_CONVERT/**"
17+ - "SQL/**"
18+ - "install/**"
A .dockerignore +20-0 View file
@@ -0,0 +1,20 @@
1+.git
2+.github
3+.idea
4+release
5+vendor
6+node_modules
7+config.local.php
8+config.php.bak
9+install/installed.lock
10+engine/cache/*
11+engine/img/theme/*
12+*.rar
13+PULL_REQUEST.md
14+upload_github
15+plugins/*/
16+layouts/*/
17+!layouts/default
18+!layouts/_example
19+!layouts/_childexample
20+!layouts/tibiacom_original
A .editorconfig +12-0 View file
@@ -0,0 +1,12 @@
1+root = true
2+
3+[*]
4+charset = utf-8
5+end_of_line = lf
6+indent_style = tab
7+insert_final_newline = true
8+trim_trailing_whitespace = true
9+
10+[*.{php,css,html,xml,lua,js}]
11+indent_style = tab
12+indent_size = 4
A .env.example +27-0 View file
@@ -0,0 +1,27 @@
1+# Copy this file to .env and adjust as needed, then run: docker compose up -d
2+
3+# --- MySQL -------------------------------------------------------------
4+MYSQL_ROOT_PASSWORD=znotex_root
5+MYSQL_DATABASE=znotex
6+MYSQL_USER=znotex
7+MYSQL_PASSWORD=znotex
8+
9+# --- ZnoteX --------------------------------------------------------------
10+ZNOTE_SITE_URL=http://localhost:8080
11+ZNOTE_SITE_TITLE=ZnoteX
12+# One of: TFS_10 (TFS 1.1-1.4.2), TFS_16 (TFS 1.6), CANARY (Canary/OTServBR-Global),
13+# TFS_03 (TFS 0.3.6+/0.4/OTX), TFS_02 (TFS 0.2.13+), OTHIRE.
14+# Picks which game database schema gets imported on first boot - only TFS_10 ships
15+# with demo accounts/characters, the others get the correct empty schema.
16+# Changing this after the first `docker compose up -d` has no effect; wipe the db
17+# volume (docker compose down -v) to switch engines.
18+ZNOTE_SERVER_ENGINE=TFS_10
19+# Account name granted admin panel access - matches the TFS_10 demo data seed.
20+ZNOTE_ADMIN_ACCOUNT=demo
21+
22+# --- Ports on your machine ------------------------------------------------
23+ZNOTEX_HTTP_PORT=8080
24+ZNOTEX_DB_PORT=3306
25+ZNOTEX_PMA_PORT=8081
26+ZNOTEX_MAILPIT_SMTP_PORT=1025
27+ZNOTEX_MAILPIT_WEB_PORT=8025
A .gitattributes +16-0 View file
@@ -0,0 +1,16 @@
1+# Set the default behavior, in case people don't have core.autocrlf set.
2+* text=auto
3+
4+# Declare files that will always have LF line endings on checkout.
5+*.php text eol=lf
6+*.lua text eol=lf
7+*.html text eol=lf
8+*.css text eol=lf
9+*.js text eol=lf
10+*.xml text eol=lf
11+
12+*.sql text eol=crlf
13+
14+# Denote all files that are truly binary and should not be modified.
15+*.png binary
16+*.jpg binary
A .github/workflows/php-compatibility.yml +50-0 View file
@@ -0,0 +1,50 @@
1+name: PHP Compatibility
2+
3+on:
4+ push:
5+ branches:
6+ - main
7+ pull_request:
8+ branches:
9+ - main
10+ workflow_dispatch:
11+
12+permissions:
13+ contents: read
14+
15+jobs:
16+ php-syntax:
17+ name: PHP ${{ matrix.php-version }}
18+ runs-on: ubuntu-latest
19+
20+ strategy:
21+ fail-fast: false
22+ matrix:
23+ php-version:
24+ - '8.1'
25+ - '8.2'
26+ - '8.3'
27+ - '8.4'
28+ - '8.5'
29+
30+ steps:
31+ - name: Checkout repository
32+ uses: actions/checkout@v4
33+
34+ - name: Setup PHP ${{ matrix.php-version }}
35+ uses: shivammathur/setup-php@v2
36+ with:
37+ php-version: ${{ matrix.php-version }}
38+ extensions: mysqli, curl, openssl, gd
39+ coverage: none
40+
41+ - name: Show PHP version
42+ run: php -v
43+
44+ - name: Check PHP syntax
45+ run: |
46+ find . \
47+ -type f \
48+ -name "*.php" \
49+ -not -path "./vendor/*" \
50+ -print0 | xargs -0 -r -n1 php -l
A .gitignore +35-0 View file
@@ -0,0 +1,35 @@
1+*.cache.php
2+engine/cache/*
3+.idea
4+vendor/
5+/release/
6+# Theme packaging and its output. This belongs on the layouts branch, not here:
7+# the code branch stays small, and cloning it does not drag the themes along.
8+upload_github/
9+
10+# Written by the installer: database credentials and the admin names for THIS
11+# install. It must never reach a public repository.
12+config.local.php
13+config.php.bak
14+
15+# Docker environment
16+.env
17+
18+# PHPUnit's own run-history cache.
19+.phpunit.result.cache
20+
21+# The installer's lock, created when it finishes.
22+install/installed.lock
23+
24+*.rar
25+# The pull-request description. Written to be pasted into GitHub, not shipped.
26+PULL_REQUEST.md
27+
28+# Theme background/logo images uploaded from the admin panel.
29+engine/img/theme/
30+
31+# Plugins are distributed on the plugins branch and downloaded / dropped into
32+# plugins/. The code branch only ships the folder, its README and .htaccess.
33+plugins/*/
34+plugins/.*
35+!plugins/.htaccess
A .htaccess +5-0 View file
@@ -0,0 +1,5 @@
1+Options +FollowSymLinks
2+RewriteEngine On
3+RewriteCond %{REQUEST_FILENAME} !-f
4+RewriteCond %{REQUEST_FILENAME} !-d
5+RewriteRule ^(.*)$ /characterprofile.php?name=$1
A achievements.php +7-0 View file
@@ -0,0 +1,7 @@
1+<?php
2+require_once 'engine/init.php';
3+theme_open();
4+
5+view('achievements');
6+
7+theme_close();
A admin/assets/acp.js +387-0 View file
@@ -0,0 +1,387 @@
1+/* ZnoteX Admin Control Panel - day/night theme, menu search, side sections. */
2+(function () {
3+ 'use strict';
4+
5+ var root = document.documentElement;
6+ var backdrop = document.getElementById('acpBackdrop');
7+ var burger = document.getElementById('acpBurger');
8+ var filter = document.getElementById('acpFilter');
9+ var nav = document.getElementById('acpNav');
10+ var themeBtn = document.getElementById('acpTheme');
11+ var MOBILE = '(max-width: 840px)';
12+
13+ function store(key, value) {
14+ try { localStorage.setItem('acp.' + key, value); } catch (e) {}
15+ }
16+
17+ function isMobile() {
18+ return window.matchMedia(MOBILE).matches;
19+ }
20+
21+ function openDrawer(open) {
22+ root.classList.toggle('acp-nav-open', open);
23+ if (backdrop) { backdrop.hidden = !open; }
24+ }
25+
26+ if (burger) {
27+ burger.addEventListener('click', function () {
28+ if (isMobile()) {
29+ openDrawer(!root.classList.contains('acp-nav-open'));
30+ return;
31+ }
32+
33+ var closed = root.classList.toggle('acp-sidebar-closed');
34+ store('sidebar_closed', closed ? '1' : '0');
35+ });
36+ }
37+ if (backdrop) {
38+ backdrop.addEventListener('click', function () { openDrawer(false); });
39+ }
40+ document.addEventListener('keydown', function (e) {
41+ if (e.key === 'Escape') { openDrawer(false); }
42+ });
43+ window.addEventListener('resize', function () {
44+ if (!isMobile()) { openDrawer(false); }
45+ });
46+
47+ if (nav) {
48+ Array.prototype.slice.call(nav.querySelectorAll('.acp-nav-group')).forEach(function (group) {
49+ var button = group.querySelector('.acp-nav-group-label');
50+ var active = group.querySelector('.acp-nav-link.is-active');
51+ group.classList.toggle('is-open', !!active);
52+ if (!active && group === nav.querySelector('.acp-nav-group')) {
53+ group.classList.add('is-open');
54+ }
55+ if (button) {
56+ button.setAttribute('aria-expanded', group.classList.contains('is-open') ? 'true' : 'false');
57+ button.addEventListener('click', function () {
58+ var open = group.classList.toggle('is-open');
59+ button.setAttribute('aria-expanded', open ? 'true' : 'false');
60+ });
61+ }
62+ });
63+
64+ // Same collapse behaviour, one level down: a plugin's own sub-list of
65+ // pages inside a group. Already open when one of its pages is the
66+ // current one (server-rendered with is-open), closed otherwise.
67+ Array.prototype.slice.call(nav.querySelectorAll('.acp-nav-plugin')).forEach(function (cluster) {
68+ var button = cluster.querySelector('.acp-nav-plugin-label');
69+ if (!button) { return; }
70+ button.addEventListener('click', function () {
71+ var open = cluster.classList.toggle('is-open');
72+ button.setAttribute('aria-expanded', open ? 'true' : 'false');
73+ });
74+ });
75+ }
76+
77+ if (filter && nav) {
78+ var links = Array.prototype.slice.call(nav.querySelectorAll('.acp-nav-link'));
79+ var clusters = Array.prototype.slice.call(nav.querySelectorAll('.acp-nav-plugin'));
80+ var groups = Array.prototype.slice.call(nav.querySelectorAll('.acp-nav-group'));
81+ var noMatch = nav.querySelector('.acp-nav-nomatch');
82+
83+ function applyFilter() {
84+ var q = filter.value.trim().toLowerCase();
85+ var hits = 0;
86+
87+ links.forEach(function (link) {
88+ var match = q === '' || (link.getAttribute('data-title') || '').indexOf(q) !== -1;
89+ link.parentNode.hidden = !match;
90+ if (match) { hits++; }
91+ });
92+
93+ // A plugin's own <li> never matches .acp-nav-link (it is the
94+ // cluster wrapper, not a page), so it needs its own visibility
95+ // pass based on whether any page inside it just matched above.
96+ clusters.forEach(function (cluster) {
97+ var visible = cluster.querySelectorAll('.acp-nav-link:not([hidden])').length > 0;
98+ cluster.hidden = !visible;
99+ if (q !== '' && visible) {
100+ cluster.classList.add('is-open');
101+ var pbutton = cluster.querySelector('.acp-nav-plugin-label');
102+ if (pbutton) { pbutton.setAttribute('aria-expanded', 'true'); }
103+ }
104+ });
105+
106+ groups.forEach(function (group) {
107+ var visible = group.querySelectorAll('li:not([hidden])').length > 0;
108+ group.hidden = !visible;
109+ if (q !== '' && visible) {
110+ group.classList.add('is-open');
111+ var button = group.querySelector('.acp-nav-group-label');
112+ if (button) { button.setAttribute('aria-expanded', 'true'); }
113+ }
114+ });
115+
116+ if (noMatch) { noMatch.hidden = hits > 0; }
117+ }
118+
119+ filter.addEventListener('input', applyFilter);
120+ filter.addEventListener('keydown', function (e) {
121+ if (e.key === 'Escape') {
122+ filter.value = '';
123+ applyFilter();
124+ }
125+ if (e.key === 'Enter') {
126+ var first = nav.querySelector('li:not([hidden]) .acp-nav-link');
127+ if (first) { window.location.href = first.href; }
128+ }
129+ });
130+ }
131+
132+ function syncTheme() {
133+ if (!themeBtn) { return; }
134+ var dark = root.getAttribute('data-acp-theme') === 'dark';
135+ var icon = themeBtn.querySelector('.fa');
136+ var label = themeBtn.querySelector('span');
137+ if (icon) { icon.className = 'fa ' + (dark ? 'fa-sun-o' : 'fa-moon-o'); }
138+ if (label) { label.textContent = dark ? 'Day' : 'Night'; }
139+ themeBtn.title = dark ? 'Switch to day mode' : 'Switch to night mode';
140+ }
141+
142+ if (themeBtn) {
143+ themeBtn.addEventListener('click', function () {
144+ var dark = root.getAttribute('data-acp-theme') === 'dark';
145+ root.setAttribute('data-acp-theme', dark ? 'light' : 'dark');
146+ store('theme', dark ? 'light' : 'dark');
147+ syncTheme();
148+ });
149+ syncTheme();
150+ }
151+
152+ var shopPreview = document.getElementById('shopOfferPreview');
153+ var shopType = document.getElementById('type');
154+ var shopItem = document.getElementById('itemid');
155+ var shopCount = document.getElementById('count');
156+
157+ if (shopPreview && shopType && shopItem && shopCount) {
158+ function image(src, className) {
159+ var img = document.createElement('img');
160+ img.src = src;
161+ img.className = className;
162+ img.alt = '';
163+ return img;
164+ }
165+
166+ function outfitUrl(outfitId, mountId) {
167+ var server = shopPreview.getAttribute('data-outfit-server') || '';
168+ var count = parseInt(shopCount.value, 10) || 0;
169+ if (!server || !outfitId) { return ''; }
170+
171+ var url = server + '?id=' + encodeURIComponent(outfitId)
172+ + '&addons=' + encodeURIComponent(count)
173+ + '&head=78&body=68&legs=58&feet=76&direction=2';
174+
175+ if (mountId) {
176+ url += '&mount=' + encodeURIComponent(mountId);
177+ }
178+
179+ return url;
180+ }
181+
182+ function updateShopPreview() {
183+ var type = parseInt(shopType.value, 10) || 0;
184+ var raw = shopItem.value.trim();
185+ var template = shopPreview.getAttribute('data-item-template') || '';
186+
187+ shopPreview.innerHTML = '';
188+
189+ if (type === 5) {
190+ var pair = raw.match(/^\s*(\d+)\s*,\s*(\d+)\s*$/);
191+ if (pair) {
192+ shopPreview.appendChild(image(outfitUrl(pair[1], ''), 'acp-shop-preview-img'));
193+ shopPreview.appendChild(image(outfitUrl(pair[2], ''), 'acp-shop-preview-img'));
194+ return;
195+ }
196+ } else if (type === 6) {
197+ if (/^\d+$/.test(raw)) {
198+ shopPreview.appendChild(image(outfitUrl(128, raw), 'acp-shop-preview-img'));
199+ return;
200+ }
201+ } else if (/^\d+$/.test(raw) && template) {
202+ shopPreview.appendChild(image(template.replace('{id}', raw), 'acp-shop-preview-item'));
203+ return;
204+ }
205+
206+ var muted = document.createElement('span');
207+ muted.className = 'is-muted';
208+ muted.appendChild(document.createTextNode('Preview'));
209+ shopPreview.appendChild(muted);
210+ }
211+
212+ shopType.addEventListener('change', updateShopPreview);
213+ shopItem.addEventListener('input', updateShopPreview);
214+ shopCount.addEventListener('input', updateShopPreview);
215+ updateShopPreview();
216+ }
217+
218+ document.addEventListener('submit', function (e) {
219+ var form = e.target;
220+ var message = form.getAttribute('data-confirm');
221+ if (message && !window.confirm(message)) {
222+ e.preventDefault();
223+ }
224+ });
225+})();
226+
227+/*
228+ * Click-to-sort for any <table class="acp-table" data-sortable>. Sorts the
229+ * rows currently in the DOM - on a paginated list that is only the current
230+ * page, which is fine: the point is letting an admin re-order what is on
231+ * screen (highest points first, most recent first), not a full-dataset sort.
232+ */
233+(function () {
234+ function cellText(row, index) {
235+ var cell = row.children[index];
236+ if (!cell) return '';
237+ return (cell.getAttribute('data-sort-value') || cell.textContent || '').trim();
238+ }
239+
240+ function looksNumeric(value) {
241+ return value !== '' && !isNaN(parseFloat(value.replace(/[^0-9.\-]/g, ''))) && /^[\s0-9.,\-]+$/.test(value);
242+ }
243+
244+ Array.prototype.slice.call(document.querySelectorAll('table.acp-table[data-sortable]')).forEach(function (table) {
245+ var thead = table.querySelector('thead');
246+ var tbody = table.querySelector('tbody');
247+ if (!thead || !tbody) return;
248+
249+ var headers = Array.prototype.slice.call(thead.querySelectorAll('th'));
250+
251+ headers.forEach(function (th, index) {
252+ if (th.textContent.trim() === '') return; // icon/action-only columns stay unsortable
253+
254+ th.classList.add('acp-th-sortable');
255+ th.setAttribute('role', 'button');
256+ th.setAttribute('tabindex', '0');
257+
258+ var sort = function () {
259+ var dir = th.getAttribute('data-sort-dir') === 'asc' ? 'desc' : 'asc';
260+ headers.forEach(function (h) {
261+ h.removeAttribute('data-sort-dir');
262+ h.classList.remove('is-sorted-asc', 'is-sorted-desc');
263+ });
264+ th.setAttribute('data-sort-dir', dir);
265+ th.classList.add(dir === 'asc' ? 'is-sorted-asc' : 'is-sorted-desc');
266+
267+ var rows = Array.prototype.slice.call(tbody.querySelectorAll(':scope > tr'));
268+ var numeric = rows.length > 0 && looksNumeric(cellText(rows[0], index));
269+
270+ rows.sort(function (a, b) {
271+ var av = cellText(a, index);
272+ var bv = cellText(b, index);
273+ var cmp;
274+ if (numeric) {
275+ cmp = (parseFloat(av.replace(/[^0-9.\-]/g, '')) || 0) - (parseFloat(bv.replace(/[^0-9.\-]/g, '')) || 0);
276+ } else {
277+ cmp = av.localeCompare(bv, undefined, { sensitivity: 'base', numeric: true });
278+ }
279+ return dir === 'asc' ? cmp : -cmp;
280+ });
281+
282+ rows.forEach(function (row) { tbody.appendChild(row); });
283+ };
284+
285+ th.addEventListener('click', sort);
286+ th.addEventListener('keydown', function (e) {
287+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); sort(); }
288+ });
289+ });
290+ });
291+})();
292+
293+/*
294+ * Client-side row filter for small lists (plugins, shop offers - anything
295+ * that is already fully rendered on the page, not a paginated server query).
296+ * Usage: <input data-acp-search-input="tableId"> filters
297+ * #tableId tbody tr[data-acp-search] by substring match, and optionally
298+ * updates a live count in [data-acp-search-count="tableId"].
299+ */
300+(function () {
301+ Array.prototype.slice.call(document.querySelectorAll('[data-acp-search-input]')).forEach(function (input) {
302+ var tableId = input.getAttribute('data-acp-search-input');
303+ var table = document.getElementById(tableId);
304+ if (!table) return;
305+
306+ var rows = Array.prototype.slice.call(table.querySelectorAll('tbody tr[data-acp-search]'));
307+ var count = document.querySelector('[data-acp-search-count="' + tableId + '"]');
308+
309+ input.addEventListener('keyup', function () {
310+ var needle = input.value.trim().toLowerCase();
311+ var shown = 0;
312+ rows.forEach(function (row) {
313+ var match = needle === '' || (row.getAttribute('data-acp-search') || '').indexOf(needle) !== -1;
314+ row.hidden = !match;
315+ if (match) shown++;
316+ });
317+ if (count) count.textContent = needle === '' ? '' : (shown + ' / ' + rows.length);
318+ });
319+ });
320+})();
321+
322+/*
323+ * Dynamic add/remove-row tables: any element with data-acp-table-add="key"
324+ * clones the row template registered as data-acp-table-template="key" into
325+ * the matching [data-acp-table="key"] container; data-acp-table-remove
326+ * removes its own row. Shared by settings.php's 'table' schema fields and
327+ * the serverdata single-record editors (items/creatures attribute rows).
328+ */
329+(function () {
330+ document.addEventListener('click', function (e) {
331+ var addBtn = e.target.closest('[data-acp-table-add]');
332+ if (addBtn) {
333+ var key = addBtn.getAttribute('data-acp-table-add');
334+ var container = document.querySelector('[data-acp-table="' + key + '"]');
335+ var template = document.querySelector('template[data-acp-table-template="' + key + '"]');
336+ if (!container || !template) return;
337+ var body = container.querySelector('[data-acp-table-body]') || container;
338+ // <tr> fragments only parse correctly inside a <tbody> wrapper; anything
339+ // else (the permissions table's plain <div> account blocks) parses fine
340+ // in a generic <div>.
341+ var wrapper = document.createElement(body.tagName === 'TBODY' ? 'tbody' : 'div');
342+ var nextIndex = body.children.length;
343+ var html = template.innerHTML.split('__ROWIDX__').join(String(nextIndex));
344+ wrapper.innerHTML = html;
345+ body.appendChild(wrapper.firstElementChild);
346+ return;
347+ }
348+
349+ var removeBtn = e.target.closest('[data-acp-table-remove]');
350+ if (removeBtn) {
351+ var row = removeBtn.closest('tr, .acp-perm-account');
352+ if (row) row.remove();
353+ }
354+ });
355+})();
356+
357+/* Ctrl+K / Cmd+K jumps focus to the top-bar search, from anywhere in the panel. */
358+(function () {
359+ document.addEventListener('keydown', function (e) {
360+ if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'k') return;
361+ var input = document.getElementById('acpTopSearch');
362+ if (!input) return;
363+ e.preventDefault();
364+ input.focus();
365+ input.select();
366+ });
367+})();
368+
369+/* Arriving from search: highlight the field the anchor points at. */
370+(function () {
371+ var id = (window.location.hash || '').replace('#', '');
372+ if (!id) return;
373+
374+ var el = document.getElementById(id);
375+ if (!el) return;
376+
377+ var field = el.closest ? el.closest('.acp-field') : null;
378+ var target = field || el;
379+
380+ target.classList.add('acp-field--found');
381+ setTimeout(function () { target.classList.remove('acp-field--found'); }, 2600);
382+
383+ setTimeout(function () {
384+ target.scrollIntoView({ behavior: 'smooth', block: 'center' });
385+ if (el.focus && el.type !== 'checkbox') { try { el.focus({ preventScroll: true }); } catch (e) {} }
386+ }, 60);
387+})();
A admin/index.php +77-0 View file
@@ -0,0 +1,77 @@
1+<?php
2+/**
3+ * ZnoteX Admin Control Panel - single entry point.
4+ */
5+
6+define('ACP_ROOT', __DIR__);
7+
8+chdir(dirname(__DIR__));
9+
10+require_once 'engine/init.php';
11+
12+if (user_logged_in() !== true) {
13+ header('Location: ../protected.php');
14+ exit;
15+}
16+
17+if (!has_admin_panel_access($user_data ?? null)) {
18+ acp_log('access.panel_denied', (string)($_GET['p'] ?? 'dashboard'));
19+ header('Location: ../myaccount.php');
20+ exit;
21+}
22+
23+if (znote2fa_setup_incomplete((int)$session_user_id, true)) {
24+ header('Location: ../twofa.php');
25+ exit;
26+}
27+
28+require_once ACP_ROOT . '/bootstrap.php';
29+
30+$acp_modules = acp_modules();
31+
32+$acp_page = (string)($_GET['p'] ?? 'dashboard');
33+if (!isset($acp_modules[$acp_page])) {
34+ $acp_page = isset($acp_modules['dashboard'])
35+ ? 'dashboard'
36+ : (string)(array_key_first($acp_modules) ?? '');
37+}
38+
39+$acp_module = $acp_modules[$acp_page] ?? null;
40+
41+if ($acp_module !== null && !acp_can_module($acp_page)) {
42+ acp_log('access.module_denied', $acp_page, ['roles' => admin_roles($user_data)]);
43+ http_response_code(403);
44+ die('You do not have permission to access this admin module.');
45+}
46+
47+// A nav entry that points somewhere else on the site is a link, not a page.
48+if ($acp_module !== null && !empty($acp_module['url'])) {
49+ header('Location: ' . $acp_module['url']);
50+ exit;
51+}
52+
53+// A POST larger than post_max_size reaches PHP with $_POST and $_FILES both
54+// emptied, which would otherwise look like a forged request.
55+if ($_SERVER['REQUEST_METHOD'] === 'POST'
56+ && !$_POST && !$_FILES
57+ && (int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0
58+) {
59+ http_response_code(413);
60+ die('That upload was larger than post_max_size in php.ini, so PHP discarded it. Raise post_max_size and upload_max_filesize, then try again.');
61+}
62+
63+if ($_SERVER['REQUEST_METHOD'] === 'POST' && !acp_verify_csrf()) {
64+ acp_log('security.csrf_rejected', $acp_page);
65+ http_response_code(400);
66+ die('Invalid CSRF token. Reload the page and try again.');
67+}
68+
69+ob_start();
70+if ($acp_module !== null) {
71+ include $acp_module['file'];
72+} else {
73+ acp_empty('No admin modules are installed in admin/modules/.', 'fa-plug');
74+}
75+$acp_content = ob_get_clean();
76+
77+include ACP_ROOT . '/layout/shell.php';
A admin/layout/shell.php +218-0 View file
@@ -0,0 +1,218 @@
1+<?php
2+/**
3+ * ZnoteX ACP shell - sidebar panel with ZnoteX styling.
4+ * Rendered by admin/index.php with $acp_content already built.
5+ *
6+ * @var array $acp_modules
7+ * @var array|null $acp_module
8+ * @var string $acp_page
9+ * @var string $acp_content
10+ */
11+
12+if (!defined('ACP_ROOT')) {
13+ http_response_code(403);
14+ die('Direct access denied.');
15+}
16+
17+$acp_flashes = acp_take_flashes();
18+$acp_groups = acp_nav_groups();
19+$acp_admin = (string)($user_data['name'] ?? 'Admin');
20+$acp_siteName = (string)($config['site_title'] ?? 'ZnoteX');
21+$acp_title = $acp_module['title'] ?? 'Admin Panel';
22+$acp_engine = serverEngineReal();
23+?>
24+<!DOCTYPE html>
25+<html lang="en" dir="ltr">
26+<head>
27+ <meta charset="utf-8">
28+ <meta name="viewport" content="width=device-width, initial-scale=1">
29+ <meta name="robots" content="noindex, nofollow">
30+ <title><?= h($acp_title) ?> &middot; <?= h($acp_siteName) ?> ACP</title>
31+
32+ <link rel="icon" href="../assets/img/znoteX.png">
33+ <link rel="shortcut icon" href="../assets/img/znoteX.png">
34+ <link rel="apple-touch-icon" href="../assets/img/znoteX.png">
35+
36+ <link rel="stylesheet" href="../assets/fontawesome/css/font-awesome.min.css?acp=1">
37+ <link rel="stylesheet" href="assets/acp.css?acp=6">
38+
39+ <script>
40+ // Applied before first paint so the theme never flashes light-then-dark.
41+ (function () {
42+ try {
43+ var d = document.documentElement;
44+ var t = localStorage.getItem('acp.theme');
45+ if (t) { d.setAttribute('data-acp-theme', t); }
46+ if (localStorage.getItem('acp.sidebar_closed') === '1') { d.classList.add('acp-sidebar-closed'); }
47+ } catch (e) {}
48+ })();
49+ </script>
50+</head>
51+<body class="acp acp-page-<?= h($acp_page) ?>">
52+
53+<div class="acp-shell">
54+
55+ <aside class="acp-sidebar" id="acpSidebar">
56+ <a class="acp-side-home" href="<?= h(acp_url('dashboard')) ?>">
57+ <i class="fa fa-home"></i>
58+ <span><?= h($acp_siteName) ?></span>
59+ </a>
60+
61+ <div class="acp-nav-filter">
62+ <i class="fa fa-search"></i>
63+ <input type="search" id="acpFilter" placeholder="<?= h(t('acp.shell.search_panel')) ?>" autocomplete="off" aria-label="<?= h(t('acp.shell.nav_label')) ?>">
64+ </div>
65+
66+ <nav class="acp-nav" id="acpNav" aria-label="<?= h(t('acp.shell.nav_label')) ?>">
67+ <?php
68+ // A little helper, inline since it is only ever used right here:
69+ // renders one nav link <li>, identical whether it sits directly in
70+ // a group or inside a plugin's sub-list.
71+ $acp_nav_link = function (string $key, array $mod, string $groupName): void {
72+ $isExternal = !empty($mod['url']);
73+ $href = $isExternal ? $mod['url'] : acp_url($key);
74+ $badge = acp_badge($key);
75+ $active = (!$isExternal && $key === $GLOBALS['acp_page']);
76+ ?>
77+ <li>
78+ <a class="acp-nav-link<?= $active ? ' is-active' : '' ?>"
79+ href="<?= h($href) ?>"
80+ data-title="<?= h(strtolower($mod['title'] . ' ' . $groupName)) ?>"
81+ <?= $isExternal ? 'target="' . h($mod['target'] ?? '_self') . '"' : '' ?>>
82+ <i class="fa <?= h($mod['icon']) ?>"></i>
83+ <span class="acp-nav-text"><?= h($mod['title']) ?></span>
84+ <?php if ($badge !== null): ?>
85+ <span class="acp-nav-badge"><?= (int)$badge ?></span>
86+ <?php elseif ($isExternal): ?>
87+ <i class="fa fa-external-link acp-nav-ext"></i>
88+ <?php endif; ?>
89+ </a>
90+ </li>
91+ <?php
92+ };
93+ ?>
94+ <?php foreach ($acp_groups as $groupName => $groupModules):
95+ $acp_cluster = acp_nav_cluster($groupModules);
96+ ?>
97+ <div class="acp-nav-group">
98+ <button type="button" class="acp-nav-group-label" aria-expanded="true">
99+ <span><?= h($groupName) ?></span>
100+ <i class="fa fa-angle-down"></i>
101+ </button>
102+ <ul>
103+ <?php foreach ($acp_cluster['plain'] as $key => $mod): $acp_nav_link($key, $mod, $groupName); endforeach; ?>
104+
105+ <?php foreach ($acp_cluster['plugins'] as $pluginKey => $cluster):
106+ $pluginActive = array_key_exists($acp_page, $cluster['items']);
107+ ?>
108+ <li class="acp-nav-plugin<?= $pluginActive ? ' is-open' : '' ?>">
109+ <button type="button" class="acp-nav-plugin-label" aria-expanded="<?= $pluginActive ? 'true' : 'false' ?>">
110+ <i class="fa fa-plug"></i>
111+ <span class="acp-nav-text"><?= h($cluster['label']) ?></span>
112+ <i class="fa fa-angle-right acp-nav-caret"></i>
113+ </button>
114+ <ul>
115+ <?php foreach ($cluster['items'] as $key => $mod): $acp_nav_link($key, $mod, $groupName . ' ' . $cluster['label']); endforeach; ?>
116+ </ul>
117+ </li>
118+ <?php endforeach; ?>
119+ </ul>
120+ </div>
121+ <?php endforeach; ?>
122+ <p class="acp-nav-nomatch" hidden><?= t('acp.shell.no_match') ?></p>
123+ </nav>
124+ </aside>
125+
126+ <div class="acp-backdrop" id="acpBackdrop" hidden></div>
127+
128+ <section class="acp-panel">
129+ <header class="acp-topbar">
130+ <button type="button" class="acp-icon-btn acp-burger" id="acpBurger" aria-label="<?= h(t('acp.shell.toggle_menu')) ?>">
131+ <i class="fa fa-bars"></i>
132+ </button>
133+ <div class="acp-top-title">
134+ <strong><?= h($acp_siteName) ?></strong>
135+ <span><?= h($acp_engine) ?> <?= t('acp.shell.control_panel') ?></span>
136+ </div>
137+ <form class="acp-top-search" method="get" action="index.php" role="search">
138+ <input type="hidden" name="p" value="search">
139+ <i class="fa fa-search"></i>
140+ <input type="search" id="acpTopSearch" name="q" list="acpSearchList"
141+ value="<?= h($acp_page === 'search' ? ($_GET['q'] ?? '') : '') ?>"
142+ placeholder="<?= h(t('acp.shell.search_the_panel')) ?>" autocomplete="off" aria-label="<?= h(t('acp.shell.search_the_panel')) ?>">
143+ <kbd class="acp-search-kbd" aria-hidden="true">Ctrl K</kbd>
144+ <datalist id="acpSearchList">
145+ <?php foreach (array_slice(acp_search_index(), 0, 300) as $acp_hit): ?>
146+ <option value="<?= h($acp_hit['title']) ?>"></option>
147+ <?php endforeach; ?>
148+ </datalist>
149+ </form>
150+
151+ <div class="acp-top-actions">
152+ <a class="acp-icon-btn" href="../index.php" title="<?= h(t('acp.shell.view_site')) ?>" aria-label="<?= h(t('acp.shell.view_site')) ?>">
153+ <i class="fa fa-globe"></i>
154+ </a>
155+ <button type="button" class="acp-theme-toggle" id="acpTheme" title="<?= h(t('acp.shell.toggle_theme')) ?>" aria-label="<?= h(t('acp.shell.toggle_theme')) ?>">
156+ <i class="fa fa-moon-o"></i>
157+ <span><?= t('acp.shell.night') ?></span>
158+ </button>
159+ <span class="acp-user">
160+ <i class="fa fa-user-circle-o"></i>
161+ <span class="acp-user-name"><?= h($acp_admin) ?></span>
162+ </span>
163+ <a class="acp-icon-btn" href="../login.php?logout" title="<?= h(t('acp.shell.log_out')) ?>" aria-label="<?= h(t('acp.shell.log_out')) ?>">
164+ <i class="fa fa-sign-out"></i>
165+ </a>
166+ </div>
167+ </header>
168+
169+ <main class="acp-content">
170+
171+ <div class="acp-page-head">
172+ <?php if ($acp_page !== 'dashboard'): ?>
173+ <nav class="acp-breadcrumb" aria-label="<?= h(t_default('acp.shell.breadcrumb_label', 'Breadcrumb')) ?>">
174+ <a href="<?= h(acp_url('dashboard')) ?>"><?= h(t('acp.mod.dashboard.title')) ?></a>
175+ <?php if (!empty($acp_module['group'])): ?>
176+ <span class="acp-breadcrumb-sep">/</span>
177+ <span><?= h($acp_module['group']) ?></span>
178+ <?php endif; ?>
179+ <span class="acp-breadcrumb-sep">/</span>
180+ <span class="acp-breadcrumb-current"><?= h($acp_title) ?></span>
181+ </nav>
182+ <?php endif; ?>
183+ <h1><?= h($acp_title) ?></h1>
184+ <?php if (!empty($acp_module['description'])): ?>
185+ <p><?= h($acp_module['description']) ?></p>
186+ <?php endif; ?>
187+ </div>
188+
189+ <?php foreach ($acp_flashes as $flash): ?>
190+ <div class="acp-flash acp-flash--<?= h($flash['type']) ?>">
191+ <i class="fa <?= $flash['type'] === 'success' ? 'fa-check-circle' : ($flash['type'] === 'error' ? 'fa-exclamation-triangle' : 'fa-info-circle') ?>"></i>
192+ <span><?= $flash['message'] ?></span>
193+ </div>
194+ <?php endforeach; ?>
195+
196+ <?php if (!acp_log_table_exists()): ?>
197+ <div class="acp-flash acp-flash--error">
198+ <i class="fa fa-exclamation-triangle"></i>
199+ <span>Administrative audit logging is unavailable because <code>znote_admin_log</code> is missing. Sensitive changes will not be traceable until the migration is applied.</span>
200+ </div>
201+ <?php endif; ?>
202+
203+ <?= $acp_content ?>
204+ </main>
205+
206+ <footer class="acp-footer">
207+ <span>&copy; <?= h($acp_siteName) ?> &middot; ZnoteX <?= h($version ?? '') ?></span>
208+ <span>
209+ <?= t('acp.shell.rendered_in', ['seconds' => elapsedTime()]) ?>
210+ &middot; <?= h(getClock(false, true)) ?>
211+ </span>
212+ </footer>
213+ </section>
214+</div>
215+
216+<script src="assets/acp.js?acp=3"></script>
217+</body>
218+</html>
Top