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 success.php +5-0 View file
@@ -0,0 +1,5 @@
1+<?php require_once 'engine/init.php'; theme_open();
2+
3+view('success');
4+
5+theme_close();
A support.php +30-0 View file
@@ -0,0 +1,30 @@
1+<?php require_once 'engine/init.php'; theme_open();
2+
3+// Staff list, grouped by position. Cached; the view only renders $srtGrp.
4+$cache = new Cache('engine/cache/support');
5+if ($cache->hasExpired()) {
6+ // Fetch all staffs in-game.
7+ if (znote_server_adapter()->normalizedEngine() === 'TFS_03') {
8+ $staffs = support_list03();
9+ } else $staffs = support_list();
10+ // Fetch group ids and names from config.php
11+ $groups = $config['ingame_positions'];
12+ // Loops through groups, separating each group element into an ID variable and name variable
13+ foreach ($groups as $group_id => $group_name) {
14+ // Loops through list of staffs
15+ if (!empty($staffs))
16+ foreach ($staffs as $staff) {
17+ if ($staff['group_id'] == $group_id) $srtGrp[$group_name][] = $staff;
18+ }
19+ }
20+ if (!empty($srtGrp)) {
21+ $cache->setContent($srtGrp);
22+ $cache->save();
23+ }
24+} else {
25+ $srtGrp = $cache->load();
26+}
27+
28+view('support');
29+
30+theme_close();
A team.php +32-0 View file
@@ -0,0 +1,32 @@
1+<?php require_once 'engine/init.php'; theme_open();
2+
3+
4+$staffChars = db()->fetchAll("
5+ SELECT `id`, `name`, `group_id`, `sex`, `looktype`, `lookhead`, `lookbody`, `looklegs`, `lookfeet`, `lookaddons`
6+ FROM `players`
7+ WHERE `group_id` > 1
8+ ORDER BY `group_id` DESC, `name` ASC;
9+");
10+$staffChars = is_array($staffChars) ? $staffChars : array();
11+
12+$staffGroups = array();
13+foreach ($staffChars as $member) {
14+ $gid = (int)$member['group_id'];
15+ if (!isset($staffGroups[$gid])) {
16+ $staffGroups[$gid] = array(
17+ 'label' => group_id_to_name($gid) ?: t('team.unnamed_group', ['id' => $gid]),
18+ 'members' => array(),
19+ );
20+ }
21+ $staffGroups[$gid]['members'][] = $member;
22+}
23+krsort($staffGroups);
24+
25+$loadOutfits = !empty($config['show_outfits']['imageServer']);
26+
27+view('team', [
28+ 'staffGroups' => $staffGroups,
29+ 'loadOutfits' => $loadOutfits,
30+]);
31+
32+theme_close();
A tests/bootstrap.php +26-0 View file
@@ -0,0 +1,26 @@
1+<?php
2+
3+require_once dirname(__DIR__) . '/vendor/autoload.php';
4+
5+define('ACP_ROOT', dirname(__DIR__) . '/admin');
6+
7+require_once dirname(__DIR__) . '/engine/function/general.php';
8+require_once dirname(__DIR__) . '/engine/function/extensions.php';
9+require_once dirname(__DIR__) . '/engine/function/plugins.php';
10+require_once dirname(__DIR__) . '/engine/function/migrations.php';
11+require_once dirname(__DIR__) . '/engine/function/updater.php';
12+require_once dirname(__DIR__) . '/engine/function/payments.php';
13+require_once dirname(__DIR__) . '/admin/bootstrap.php';
14+
15+if (!function_exists('t_default')) {
16+ function t_default(string $key, string $default = ''): string {
17+ return $default;
18+ }
19+}
20+
21+if (!function_exists('theme_sanitize')) {
22+ function theme_sanitize(string $name): string {
23+ $name = strtolower(trim($name));
24+ return preg_match('/^[a-z0-9_-]{1,64}$/', $name) === 1 ? $name : '';
25+ }
26+}
A tests/fixtures/test-update-private.pem +28-0 View file
@@ -0,0 +1,28 @@
1+-----BEGIN PRIVATE KEY-----
2+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDrT8altlCv5VJp
3+0bveLec+5DUz1RtpZDJXWKYQnqqyJXsYjcCQhU5mn6CH5Gelbtc6FBAyWH4pPLcR
4+oZzkD0xTvZboHXMzZbvdHVqwWbc3nTHkKeHwQvAmUzOlrc7rwB6pZWtBdz3RsJPW
5+n1hgeDwnZKnsgtK7IAXsBliBEnWOpYn70fQnCq7cwgJIF+FkWKmUpuB3/d75+0Ua
6+rhXEBjaq+sOc9pSdhduRHsr1a7zU3YyxssvBeuniybT5BMGDk5o1XT17NfoFHK5W
7+MpXGn59VL3C1wUrXW4GHFZ2imCTIeDRHHZZ6I2M98hCOUQeOHoU1VlFZRioFJasv
8+s8Xgqbv1AgMBAAECggEAXDCpFwtSmVcy7pppDCbXzmACKAh58NR8lJP6m9BN9/WP
9+QJNoujY//RzU++iUYtAGKo7puY/J/cX3w0SZ/w+vS6+fi9jd5WkaLQrvGOBNU9CW
10+V1nBVywigiFevq5Vvy9J0/wUBVX+NkUD5rYPHdx9VMGvgSXQAdgN+eaSjh75R6T7
11+DWB9PLFRaWOAHrKYEuIm+yoWdpYVRKQrHffqXnxlyyu/EIV6PJeJHiSKedwPXueZ
12+ZS5s3CWa1O1coFBSDmCHWdcUy7BWgRJuv5nXxY6wuqPqyOw4o7PXw0J1n/Cim22j
13+Ge1jT97jT7fCx00o6GBuzHOM0vIOqxrtfH05AzH7EQKBgQD5zj8CTlYMsDZEsebk
14+Oua3Vk3wEBa67r8a9USwNIfD/auchuFZfASmV/RRRJswWRXa1lC85QDPn7rpyLXG
15+X3FZsUh7OXwhQnFEmCdpy6SRl9lqrmQH/vsYSNHeTv8+5jneZV0Ezq9HKqRoboTP
16+ezGxa0DWV5Uj4HtP62WW/BkgLwKBgQDxJYXB+g+fUL/Pl1xaOxcoOugtdTfIaJ+X
17+hSMfOa5XzRRYTCgv50y/Hf1xXxJrwERclRLVNOjnYz2E+vo1gDwJxlcOPlMhNhPd
18+YPQ/AoicTZTSpyPQ53ynEZM4+zyqlEXEoyxRFsDFL/KVB7/u9KRAQi8SX04FksM8
19+morT/51ZGwKBgQC1OdxaVux0bg4gzhOcteKVVUZbh8CFwxjffNplHubz1/99Ihkw
20+axmQeDSmFKilbau+REb0kwqAlffrDRJapPk9wbC8vNqB4or74YqOZQ+yFEDF9Vha
21+uK//USz4I8VnI20OG+lcyHk+nwABR1SQlWZauV2jYoyvJ3cuZq8f1yp/PwKBgBoB
22+zfcpnN21u7oLvO4OSWURVVDxv15hyjRxK2SGuALIH1WWgQ8JhwFlnpvHgRkV10mU
23+2j8cQbISxeO9nZZ/ifoT5fenSRff2Sya9DyHbWxOAarmU7qH/K2X+6S9k8Fh1FRs
24+tK7aIVgi36qq90wyHjS/7ouws51uQpgaorZSbwnZAoGAePE35qaLmjvZ0m2nD7ny
25+ZF+RVyUxBVFmPeokIwQljZxfpPYe59DqCUcTHg3HNQ9jwVyJL3GHTpcFdeKEG4If
26+iq5/pHNz7pA7+3H2I2robTh2qhYg1MchKRRMYYq6bsGnnWLVajXIJ4V5GMpx08ZD
27+ZF65tz7sTOgbm+sytyA7gfg=
28+-----END PRIVATE KEY-----
A tests/fixtures/test-update-public.pem +9-0 View file
@@ -0,0 +1,9 @@
1+-----BEGIN PUBLIC KEY-----
2+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA60/GpbZQr+VSadG73i3n
3+PuQ1M9UbaWQyV1imEJ6qsiV7GI3AkIVOZp+gh+RnpW7XOhQQMlh+KTy3EaGc5A9M
4+U72W6B1zM2W73R1asFm3N50x5Cnh8ELwJlMzpa3O68AeqWVrQXc90bCT1p9YYHg8
5+J2Sp7ILSuyAF7AZYgRJ1jqWJ+9H0Jwqu3MICSBfhZFiplKbgd/3e+ftFGq4VxAY2
6+qvrDnPaUnYXbkR7K9Wu81N2MsbLLwXrp4sm0+QTBg5OaNV09ezX6BRyuVjKVxp+f
7+VS9wtcFK11uBhxWdopgkyHg0Rx2WeiNjPfIQjlEHjh6FNVZRWUYqBSWrL7PF4Km7
8+9QIDAQAB
9+-----END PUBLIC KEY-----
A tests/Security/AdminPermissionsTest.php +92-0 View file
@@ -0,0 +1,92 @@
1+<?php
2+
3+declare(strict_types=1);
4+
5+namespace ZnoteX\Tests\Security;
6+
7+use PHPUnit\Framework\TestCase;
8+
9+final class AdminPermissionsTest extends TestCase
10+{
11+ protected function setUp(): void
12+ {
13+ $GLOBALS['config'] = [
14+ 'ServerEngine' => 'TFS_10',
15+ 'page_admin_access' => ['OwnerAccount'],
16+ 'page_admin_roles' => [
17+ 'ModeratorAccount' => ['gallery', 'reports'],
18+ 'SupportAccount' => 'helpdesk',
19+ ],
20+ ];
21+ }
22+
23+ public function testOwnerNameGrantsTheOwnerRole(): void
24+ {
25+ $roles = \admin_roles(['name' => 'OwnerAccount']);
26+ $this->assertSame(['owner'], $roles);
27+ }
28+
29+ public function testAssignedModulesAreHonoured(): void
30+ {
31+ $roles = \admin_roles(['name' => 'ModeratorAccount']);
32+ $this->assertSame(['gallery', 'reports'], $roles);
33+ }
34+
35+ public function testASingleAssignedModuleStringIsNormalisedToAnArray(): void
36+ {
37+ $roles = \admin_roles(['name' => 'SupportAccount']);
38+ $this->assertSame(['helpdesk'], $roles);
39+ }
40+
41+ public function testUnknownAccountGetsNoRoles(): void
42+ {
43+ $roles = \admin_roles(['name' => 'SomeoneElse']);
44+ $this->assertSame([], $roles);
45+ }
46+
47+ public function testNonArrayUserDataGetsNoRoles(): void
48+ {
49+ $this->assertSame([], \admin_roles(null));
50+ $this->assertSame([], \admin_roles(false));
51+ }
52+
53+ public function testOthireIdentityIsTheAccountIdNotTheName(): void
54+ {
55+ $GLOBALS['config']['ServerEngine'] = 'OTHIRE';
56+ $GLOBALS['config']['page_admin_access'] = [42];
57+
58+ $this->assertSame(['owner'], \admin_roles(['id' => 42, 'name' => 'OwnerAccount']));
59+ $this->assertSame([], \admin_roles(['id' => 99, 'name' => 'OwnerAccount']));
60+ }
61+
62+ public function testOwnerCanReachEveryModule(): void
63+ {
64+ $this->assertTrue(\acp_can_module('update', ['name' => 'OwnerAccount']));
65+ $this->assertTrue(\acp_can_module('settings', ['name' => 'OwnerAccount']));
66+ }
67+
68+ public function testAScopedAccountOnlyReachesItsGrantedModules(): void
69+ {
70+ $this->assertTrue(\acp_can_module('gallery', ['name' => 'ModeratorAccount']));
71+ $this->assertFalse(\acp_can_module('accounts', ['name' => 'ModeratorAccount']));
72+ }
73+
74+ public function testAScopedAccountAlwaysReachesDashboardAndSearch(): void
75+ {
76+ $this->assertTrue(\acp_can_module('dashboard', ['name' => 'ModeratorAccount']));
77+ $this->assertTrue(\acp_can_module('search', ['name' => 'ModeratorAccount']));
78+ }
79+
80+ public function testAnUngrantedAccountReachesNoModuleAtAll(): void
81+ {
82+ // 'update' was never granted to anyone in this fixture, so only the
83+ // owner bypass in acp_can_module() may reach it.
84+ $this->assertFalse(\acp_can_module('update', ['name' => 'ModeratorAccount']));
85+ $this->assertFalse(\acp_can_module('update', ['name' => 'SupportAccount']));
86+ }
87+
88+ public function testAnonymousVisitorHasNoAccess(): void
89+ {
90+ $this->assertFalse(\acp_can_module('dashboard', null));
91+ }
92+}
A tests/Security/CsrfTest.php +75-0 View file
@@ -0,0 +1,75 @@
1+<?php
2+
3+declare(strict_types=1);
4+
5+namespace ZnoteX\Tests\Security;
6+
7+use PHPUnit\Framework\TestCase;
8+
9+final class CsrfTest extends TestCase
10+{
11+ protected function setUp(): void
12+ {
13+ $_SESSION = [];
14+ $_POST = [];
15+ }
16+
17+ public function testTokenIsGeneratedOnFirstUse(): void
18+ {
19+ $this->assertArrayNotHasKey('acp_csrf', $_SESSION);
20+ $token = \acp_csrf();
21+ $this->assertNotSame('', $token);
22+ $this->assertSame($token, $_SESSION['acp_csrf']);
23+ }
24+
25+ public function testTokenIsStableAcrossCalls(): void
26+ {
27+ $first = \acp_csrf();
28+ $second = \acp_csrf();
29+ $this->assertSame($first, $second);
30+ }
31+
32+ public function testFieldEmbedsTheCurrentToken(): void
33+ {
34+ $token = \acp_csrf();
35+ $field = \acp_csrf_field();
36+ $this->assertStringContainsString('name="csrf_token"', $field);
37+ $this->assertStringContainsString(htmlspecialchars($token, ENT_QUOTES, 'UTF-8'), $field);
38+ }
39+
40+ public function testVerifyRejectsMissingToken(): void
41+ {
42+ \acp_csrf();
43+ $_POST = [];
44+ $this->assertFalse(\acp_verify_csrf());
45+ }
46+
47+ public function testVerifyRejectsWrongToken(): void
48+ {
49+ \acp_csrf();
50+ $_POST['csrf_token'] = 'attacker-supplied-value';
51+ $this->assertFalse(\acp_verify_csrf());
52+ }
53+
54+ public function testVerifyRejectsNonStringToken(): void
55+ {
56+ \acp_csrf();
57+ $_POST['csrf_token'] = ['not', 'a', 'string'];
58+ $this->assertFalse(\acp_verify_csrf());
59+ }
60+
61+ public function testVerifyAcceptsTheRealToken(): void
62+ {
63+ $token = \acp_csrf();
64+ $_POST['csrf_token'] = $token;
65+ $this->assertTrue(\acp_verify_csrf());
66+ }
67+
68+ public function testEachSessionGetsAnIndependentToken(): void
69+ {
70+ $tokenA = \acp_csrf();
71+ $_SESSION = [];
72+ $tokenB = \acp_csrf();
73+ $this->assertNotSame($tokenA, $tokenB);
74+ }
75+}
A tests/Security/GuildLogoSafeNameTest.php +50-0 View file
@@ -0,0 +1,50 @@
1+<?php
2+
3+declare(strict_types=1);
4+
5+namespace ZnoteX\Tests\Security;
6+
7+use PHPUnit\Framework\TestCase;
8+
9+final class GuildLogoSafeNameTest extends TestCase
10+{
11+ public function testAcceptsAnOrdinaryGuildName(): void
12+ {
13+ $this->assertSame('Knights of ZnoteX', \guild_logo_safe_name('Knights of ZnoteX'));
14+ }
15+
16+ public function testTrimsWhitespace(): void
17+ {
18+ $this->assertSame('Guardians', \guild_logo_safe_name(' Guardians '));
19+ }
20+
21+ public function testRejectsEmptyName(): void
22+ {
23+ $this->assertNull(\guild_logo_safe_name(''));
24+ $this->assertNull(\guild_logo_safe_name(' '));
25+ }
26+
27+ public function testRejectsNameLongerThanSixtyCharacters(): void
28+ {
29+ $this->assertNull(\guild_logo_safe_name(str_repeat('a', 61)));
30+ $this->assertNotNull(\guild_logo_safe_name(str_repeat('a', 60)));
31+ }
32+
33+ /** @dataProvider pathTraversalProvider */
34+ public function testRejectsPathTraversalAttempts(string $malicious): void
35+ {
36+ $this->assertNull(\guild_logo_safe_name($malicious));
37+ }
38+
39+ public static function pathTraversalProvider(): array
40+ {
41+ return [
42+ 'parent directory' => ['../../../etc/passwd'],
43+ 'dot dot in the middle' => ['guild..name'],
44+ 'forward slash' => ['guild/name'],
45+ 'backslash' => ['guild\\name'],
46+ 'null byte' => ["guild\0name"],
47+ 'windows traversal' => ['..\\..\\config.local.php'],
48+ ];
49+ }
50+}
A tests/Security/MigrationSqlSplitterTest.php +105-0 View file
@@ -0,0 +1,105 @@
1+<?php
2+
3+declare(strict_types=1);
4+
5+namespace ZnoteX\Tests\Security;
6+
7+use PHPUnit\Framework\TestCase;
8+
9+final class MigrationSqlSplitterTest extends TestCase
10+{
11+ public function testSplitsSimpleStatements(): void
12+ {
13+ $sql = "CREATE TABLE a (id INT);\nINSERT INTO a VALUES (1);";
14+ $this->assertSame(
15+ ['CREATE TABLE a (id INT)', 'INSERT INTO a VALUES (1)'],
16+ \znote_migration_split_sql($sql)
17+ );
18+ }
19+
20+ public function testSemicolonInsideAStringLiteralDoesNotSplit(): void
21+ {
22+ $sql = "INSERT INTO a (name) VALUES ('it;s here');";
23+ $statements = \znote_migration_split_sql($sql);
24+ $this->assertCount(1, $statements);
25+ $this->assertStringContainsString("'it;s here'", $statements[0]);
26+ }
27+
28+ public function testSemicolonInsideALineCommentDoesNotSplit(): void
29+ {
30+ // The comment has no terminating semicolon of its own, so it attaches
31+ // to the following statement as a single chunk.
32+ $sql = "-- comment ; still comment\nINSERT INTO a VALUES (1);";
33+ $statements = \znote_migration_split_sql($sql);
34+ $this->assertCount(1, $statements);
35+ $this->assertStringContainsString('INSERT INTO a VALUES (1)', $statements[0]);
36+ }
37+
38+ public function testSemicolonInsideABlockCommentDoesNotSplit(): void
39+ {
40+ $sql = "/* a ; block ; comment */ INSERT INTO a VALUES (1);";
41+ $statements = \znote_migration_split_sql($sql);
42+ $this->assertCount(1, $statements);
43+ }
44+
45+ public function testEscapedQuoteInsideAStringDoesNotCloseIt(): void
46+ {
47+ $sql = "INSERT INTO a (name) VALUES ('it\\'s a trap; still one statement');";
48+ $statements = \znote_migration_split_sql($sql);
49+ $this->assertCount(1, $statements);
50+ }
51+
52+ public function testHashStartsALineComment(): void
53+ {
54+ $sql = "# hash comment ; here\nINSERT INTO a VALUES (2);";
55+ $statements = \znote_migration_split_sql($sql);
56+ $this->assertCount(1, $statements);
57+ $this->assertStringContainsString('INSERT INTO a VALUES (2)', $statements[0]);
58+ }
59+
60+ public function testEmptyStatementsAreDropped(): void
61+ {
62+ $sql = "INSERT INTO a VALUES (1);;; ;\nINSERT INTO a VALUES (2);";
63+ $statements = \znote_migration_split_sql($sql);
64+ $this->assertCount(2, $statements);
65+ }
66+
67+ public function testTrailingStatementWithoutASemicolonIsKept(): void
68+ {
69+ $sql = "INSERT INTO a VALUES (1);\nINSERT INTO a VALUES (2)";
70+ $statements = \znote_migration_split_sql($sql);
71+ $this->assertCount(2, $statements);
72+ $this->assertSame('INSERT INTO a VALUES (2)', $statements[1]);
73+ }
74+
75+ public function testDestructiveStatementsAreRejectedByTheUpdaterSafetyCheck(): void
76+ {
77+ $this->assertFalse(\znote_update_migration_safe('DROP TABLE accounts;'));
78+ $this->assertFalse(\znote_update_migration_safe('DELETE FROM accounts;'));
79+ $this->assertFalse(\znote_update_migration_safe('UPDATE accounts SET password = \'\';'));
80+ $this->assertFalse(\znote_update_migration_safe('TRUNCATE accounts;'));
81+ }
82+
83+ public function testAdditiveStatementsAreAcceptedByTheUpdaterSafetyCheck(): void
84+ {
85+ $this->assertTrue(\znote_update_migration_safe('CREATE TABLE IF NOT EXISTS foo (id INT);'));
86+ $this->assertTrue(\znote_update_migration_safe('ALTER TABLE foo ADD COLUMN bar INT;'));
87+ $this->assertTrue(\znote_update_migration_safe("INSERT IGNORE INTO foo (id) VALUES (1);"));
88+ }
89+
90+ public function testAMixOfSafeAndDestructiveStatementsIsRejected(): void
91+ {
92+ $sql = "CREATE TABLE IF NOT EXISTS foo (id INT);\nDROP TABLE bar;";
93+ $this->assertFalse(\znote_update_migration_safe($sql));
94+ }
95+
96+ public function testDestructiveKeywordHiddenInAStringLiteralIsStillCaughtByTheRegexScan(): void
97+ {
98+ // znote_update_migration_safe() only strips comments, not string
99+ // contents, so a DROP/DELETE keyword anywhere in the statement -
100+ // even inside a quoted value - is treated as unsafe. This is a
101+ // deliberately conservative false positive, not a bypass.
102+ $sql = "INSERT IGNORE INTO foo (note) VALUES ('please DROP by later');";
103+ $this->assertFalse(\znote_update_migration_safe($sql));
104+ }
105+}
A tests/Security/PaymentWebhookTest.php +177-0 View file
@@ -0,0 +1,177 @@
1+<?php
2+
3+declare(strict_types=1);
4+
5+namespace ZnoteX\Tests\Security;
6+
7+use PHPUnit\Framework\TestCase;
8+
9+final class PaymentWebhookTest extends TestCase
10+{
11+ private const SECRET = 'whsec_test_only_1234567890';
12+
13+ protected function setUp(): void
14+ {
15+ $GLOBALS['config'] = [
16+ 'stripe' => ['webhook_secret' => self::SECRET],
17+ 'mercadopago' => ['webhook_secret' => self::SECRET],
18+ ];
19+ }
20+
21+ public function testGenuineStripeSignatureIsAccepted(): void
22+ {
23+ $payload = '{"id":"evt_1","type":"checkout.session.completed"}';
24+ $header = $this->realStripeHeader($payload);
25+
26+ $this->assertTrue(\payment_gateway_verify_stripe_signature($payload, $header));
27+ }
28+
29+ public function testForgedStripeSignatureIsRejected(): void
30+ {
31+ $payload = '{"id":"evt_1","type":"checkout.session.completed"}';
32+ $timestamp = time();
33+ $forgedSignature = hash_hmac('sha256', $timestamp . '.' . $payload, 'attacker-does-not-know-the-real-secret');
34+ $header = "t={$timestamp},v1={$forgedSignature}";
35+
36+ $this->assertFalse(\payment_gateway_verify_stripe_signature($payload, $header));
37+ }
38+
39+ public function testTamperedPayloadInvalidatesAGenuineSignature(): void
40+ {
41+ $originalPayload = '{"id":"evt_1","amount_total":100}';
42+ $header = $this->realStripeHeader($originalPayload);
43+
44+ $tamperedPayload = '{"id":"evt_1","amount_total":999999}';
45+ $this->assertFalse(\payment_gateway_verify_stripe_signature($tamperedPayload, $header));
46+ }
47+
48+ public function testReplayedOldSignatureIsRejected(): void
49+ {
50+ $payload = '{"id":"evt_1"}';
51+ $oldTimestamp = time() - 3600;
52+ $signature = hash_hmac('sha256', $oldTimestamp . '.' . $payload, self::SECRET);
53+ $header = "t={$oldTimestamp},v1={$signature}";
54+
55+ $this->assertFalse(\payment_gateway_verify_stripe_signature($payload, $header));
56+ }
57+
58+ public function testMissingHeaderIsRejected(): void
59+ {
60+ $this->assertFalse(\payment_gateway_verify_stripe_signature('{}', ''));
61+ }
62+
63+ public function testEmptyConfiguredSecretRejectsEverything(): void
64+ {
65+ $GLOBALS['config']['stripe']['webhook_secret'] = '';
66+ $payload = '{"id":"evt_1"}';
67+ $header = $this->realStripeHeader($payload, 'whatever');
68+
69+ $this->assertFalse(\payment_gateway_verify_stripe_signature($payload, $header));
70+ }
71+
72+ public function testGenuineMercadopagoSignatureIsAccepted(): void
73+ {
74+ $dataId = '123456';
75+ $requestId = 'req-1';
76+ $header = $this->realMercadopagoHeader($dataId, $requestId, $timestamp = (string)time());
77+
78+ $this->assertTrue(\payment_gateway_verify_mercadopago_signature($dataId, $requestId, $header));
79+ }
80+
81+ public function testForgedMercadopagoSignatureIsRejected(): void
82+ {
83+ $dataId = '123456';
84+ $requestId = 'req-1';
85+ $timestamp = (string)time();
86+ $manifest = 'id:' . $dataId . ';request-id:' . $requestId . ';ts:' . $timestamp . ';';
87+ $forged = hash_hmac('sha256', $manifest, 'not-the-real-secret');
88+ $header = "ts={$timestamp},v1={$forged}";
89+
90+ $this->assertFalse(\payment_gateway_verify_mercadopago_signature($dataId, $requestId, $header));
91+ }
92+
93+ public function testMercadopagoSignatureBoundToTheWrongDataIdIsRejected(): void
94+ {
95+ $requestId = 'req-1';
96+ $timestamp = (string)time();
97+ $header = $this->realMercadopagoHeader('legit-payment-id', $requestId, $timestamp);
98+
99+ // An attacker who intercepted a genuine notification for one payment
100+ // cannot replay it against a different data id.
101+ $this->assertFalse(\payment_gateway_verify_mercadopago_signature('someone-elses-payment-id', $requestId, $header));
102+ }
103+
104+ public function testAlreadyCreditedTransactionIsNeverCreditedTwice(): void
105+ {
106+ $tx = ['credited' => 1, 'account_id' => 5, 'points' => 100, 'price' => 9.99, 'currency' => 'USD'];
107+ $this->assertSame('already_credited', \payment_gateway_validate_transaction('stripe', $tx, '', []));
108+ }
109+
110+ public function testMissingTransactionIsRejected(): void
111+ {
112+ $this->assertSame('missing_transaction', \payment_gateway_validate_transaction('stripe', false, '', []));
113+ }
114+
115+ public function testProviderReferenceCannotBeSwappedAfterTheFactForStripe(): void
116+ {
117+ $tx = [
118+ 'credited' => 0,
119+ 'provider_reference' => 'pi_original_genuine_payment_intent',
120+ 'account_id' => 5,
121+ 'points' => 100,
122+ 'price' => 9.99,
123+ 'currency' => 'USD',
124+ ];
125+
126+ $result = \payment_gateway_validate_transaction('stripe', $tx, 'pi_attacker_supplied_different_intent', []);
127+ $this->assertSame('provider_reference_mismatch', $result);
128+ }
129+
130+ public function testInvalidAccountOrPointsIsRejected(): void
131+ {
132+ $tx = ['credited' => 0, 'account_id' => 0, 'points' => 100, 'price' => 9.99, 'currency' => 'USD'];
133+ $this->assertSame('invalid_transaction', \payment_gateway_validate_transaction('mercadopago', $tx, '', []));
134+
135+ $tx['account_id'] = 5;
136+ $tx['points'] = 0;
137+ $this->assertSame('invalid_transaction', \payment_gateway_validate_transaction('mercadopago', $tx, '', []));
138+ }
139+
140+ public function testAmountMismatchBlocksCrediting(): void
141+ {
142+ $tx = ['credited' => 0, 'account_id' => 5, 'points' => 100, 'price' => 9.99, 'currency' => 'USD'];
143+ $payload = ['transaction_amount' => 0.01, 'currency_id' => 'usd'];
144+
145+ $this->assertSame('amount_mismatch', \payment_gateway_validate_transaction('mercadopago', $tx, '', $payload));
146+ }
147+
148+ public function testLiveModeCannotCreditATestModeTransactionOrViceVersa(): void
149+ {
150+ $tx = ['credited' => 0, 'account_id' => 5, 'points' => 100, 'price' => 9.99, 'currency' => 'USD', 'test_mode' => 1];
151+ $payload = ['transaction_amount' => 9.99, 'currency_id' => 'usd', 'live_mode' => true];
152+
153+ $this->assertSame('mode_mismatch', \payment_gateway_validate_transaction('mercadopago', $tx, '', $payload));
154+ }
155+
156+ public function testAFullyValidTransactionPassesValidation(): void
157+ {
158+ $tx = ['credited' => 0, 'account_id' => 5, 'points' => 100, 'price' => 9.99, 'currency' => 'USD', 'test_mode' => 0];
159+ $payload = ['transaction_amount' => 9.99, 'currency_id' => 'usd', 'live_mode' => true];
160+
161+ $this->assertSame('ok', \payment_gateway_validate_transaction('mercadopago', $tx, '', $payload));
162+ }
163+
164+ private function realStripeHeader(string $payload, ?string $secret = null): string
165+ {
166+ $timestamp = time();
167+ $signature = hash_hmac('sha256', $timestamp . '.' . $payload, $secret ?? self::SECRET);
168+ return "t={$timestamp},v1={$signature}";
169+ }
170+
171+ private function realMercadopagoHeader(string $dataId, string $requestId, string $timestamp): string
172+ {
173+ $manifest = 'id:' . $dataId . ';request-id:' . $requestId . ';ts:' . $timestamp . ';';
174+ $signature = hash_hmac('sha256', $manifest, self::SECRET);
175+ return "ts={$timestamp},v1={$signature}";
176+ }
177+}
A tests/Security/PluginSanitizeTest.php +85-0 View file
@@ -0,0 +1,85 @@
1+<?php
2+
3+declare(strict_types=1);
4+
5+namespace ZnoteX\Tests\Security;
6+
7+use PHPUnit\Framework\TestCase;
8+
9+final class PluginSanitizeTest extends TestCase
10+{
11+ public function testAcceptsAnOrdinaryPluginName(): void
12+ {
13+ $this->assertSame('boosted_creatures', \znote_plugin_sanitize('boosted_creatures'));
14+ }
15+
16+ public function testLowercasesAndTrims(): void
17+ {
18+ $this->assertSame('shop-coupons', \znote_plugin_sanitize(' Shop-Coupons '));
19+ }
20+
21+ /** @dataProvider pathTraversalProvider */
22+ public function testRejectsPathTraversalAttempts(string $malicious): void
23+ {
24+ $this->assertSame('', \znote_plugin_sanitize($malicious));
25+ }
26+
27+ public static function pathTraversalProvider(): array
28+ {
29+ return [
30+ 'parent directory' => ['../../../etc/passwd'],
31+ 'nested traversal' => ['plugins/../../config.local.php'],
32+ 'absolute unix path' => ['/etc/passwd'],
33+ 'absolute windows path' => ['C:\\Windows\\System32'],
34+ 'null byte' => ["plugin\0.php"],
35+ 'slash' => ['foo/bar'],
36+ 'backslash' => ['foo\\bar'],
37+ 'empty string' => [''],
38+ 'only dots' => ['..'],
39+ 'too long' => [str_repeat('a', 65)],
40+ ];
41+ }
42+
43+ public function testExtensionApiRejectsAnInvalidPluginName(): void
44+ {
45+ $this->expectException(\InvalidArgumentException::class);
46+ \ZnoteExtensionApi::plugin('../../../etc/passwd');
47+ }
48+
49+ public function testExtensionApiAcceptsAValidPluginName(): void
50+ {
51+ $api = \ZnoteExtensionApi::plugin('boosted_creatures');
52+ $this->assertSame('boosted_creatures', $api->name());
53+ $this->assertSame('plugin', $api->kind());
54+ }
55+
56+ /** @dataProvider relativePathTraversalProvider */
57+ public function testRelativePathHelperRejectsTraversal(string $malicious): void
58+ {
59+ $this->assertSame('', \znote_extension_relative_path($malicious));
60+ }
61+
62+ public static function relativePathTraversalProvider(): array
63+ {
64+ return [
65+ 'parent directory' => ['../../../etc/passwd'],
66+ 'nested traversal' => ['css/../../config.local.php'],
67+ 'absolute windows path' => ['C:\\Windows\\System32'],
68+ 'null byte' => ["css/style\0.css"],
69+ 'only dots' => ['..'],
70+ 'single dot segment' => ['css/./style.css'],
71+ 'empty string' => [''],
72+ ];
73+ }
74+
75+ public function testRelativePathHelperAcceptsAnOrdinaryAssetPath(): void
76+ {
77+ $this->assertSame('css/style.css', \znote_extension_relative_path('css/style.css'));
78+ }
79+
80+ public function testRelativePathHelperNormalisesBackslashesAndLeadingSlash(): void
81+ {
82+ $this->assertSame('css/style.css', \znote_extension_relative_path('\\css\\style.css'));
83+ $this->assertSame('css/style.css', \znote_extension_relative_path('/css/style.css/'));
84+ }
85+}
A tests/Security/PluginSettingsTest.php +150-0 View file
@@ -0,0 +1,150 @@
1+<?php
2+
3+declare(strict_types=1);
4+
5+namespace ZnoteX\Tests\Security;
6+
7+use PHPUnit\Framework\TestCase;
8+
9+require_once dirname(__DIR__, 2) . '/engine/function/plugin_settings.php';
10+
11+final class PluginSettingsTest extends TestCase
12+{
13+ private const TEST_PLUGIN = 'zztest_settings_plugin';
14+
15+ private function pluginDir(): string {
16+ return ZNOTE_PLUGIN_DIR . '/' . self::TEST_PLUGIN;
17+ }
18+
19+ protected function tearDown(): void
20+ {
21+ $dir = $this->pluginDir();
22+ if (is_file($dir . '/settings.json')) {
23+ unlink($dir . '/settings.json');
24+ }
25+ if (is_dir($dir)) {
26+ rmdir($dir);
27+ }
28+ }
29+
30+ private function writeSchema(array $data): void
31+ {
32+ mkdir($this->pluginDir(), 0775, true);
33+ file_put_contents($this->pluginDir() . '/settings.json', json_encode($data));
34+ }
35+
36+ public function testHasIsFalseWithoutAFile(): void
37+ {
38+ $this->assertFalse(\znote_plugin_settings_has(self::TEST_PLUGIN));
39+ }
40+
41+ public function testSchemaIsEmptyForAnUnknownPlugin(): void
42+ {
43+ $this->assertSame([], \znote_plugin_settings_schema('../../../etc/passwd'));
44+ }
45+
46+ public function testSchemaParsesValidFields(): void
47+ {
48+ $this->writeSchema(['fields' => [
49+ ['key' => 'api_key', 'type' => 'text', 'label' => 'API key', 'default' => ''],
50+ ['key' => 'enabled', 'type' => 'bool', 'default' => '1'],
51+ ['key' => 'mode', 'type' => 'select', 'default' => 'test', 'options' => ['test' => 'Test', 'live' => 'Live']],
52+ ['key' => 'max_items', 'type' => 'int', 'default' => '10', 'min' => 1, 'max' => 100],
53+ ]]);
54+
55+ $schema = \znote_plugin_settings_schema(self::TEST_PLUGIN);
56+
57+ $this->assertTrue(\znote_plugin_settings_has(self::TEST_PLUGIN));
58+ $this->assertSame(['api_key', 'enabled', 'mode', 'max_items'], array_keys($schema));
59+ $this->assertSame('API key', $schema['api_key']['label']);
60+ $this->assertSame(1, $schema['max_items']['min']);
61+ $this->assertSame(100, $schema['max_items']['max']);
62+ }
63+
64+ public function testSchemaDropsFieldsWithAnUnknownType(): void
65+ {
66+ $this->writeSchema(['fields' => [
67+ ['key' => 'good', 'type' => 'text'],
68+ ['key' => 'bad', 'type' => 'not_a_real_type'],
69+ ]]);
70+
71+ $schema = \znote_plugin_settings_schema(self::TEST_PLUGIN);
72+
73+ $this->assertArrayHasKey('good', $schema);
74+ $this->assertArrayNotHasKey('bad', $schema);
75+ }
76+
77+ public function testSchemaDropsFieldsWithAnInvalidKey(): void
78+ {
79+ $this->writeSchema(['fields' => [
80+ ['key' => 'ok_key', 'type' => 'text'],
81+ ['key' => 'has spaces', 'type' => 'text'],
82+ ['key' => '../traversal', 'type' => 'text'],
83+ ['key' => '', 'type' => 'text'],
84+ ]]);
85+
86+ $schema = \znote_plugin_settings_schema(self::TEST_PLUGIN);
87+
88+ $this->assertSame(['ok_key'], array_keys($schema));
89+ }
90+
91+ public function testMalformedJsonYieldsAnEmptySchema(): void
92+ {
93+ mkdir($this->pluginDir(), 0775, true);
94+ file_put_contents($this->pluginDir() . '/settings.json', '{not valid json');
95+
96+ $this->assertSame([], \znote_plugin_settings_schema(self::TEST_PLUGIN));
97+ }
98+
99+ public function testStorageKeyMatchesTheExtensionApiNamespace(): void
100+ {
101+ $this->assertSame(
102+ 'plugin:my_plugin:setting:api_key',
103+ \znote_plugin_settings_storage_key('my_plugin', 'api_key')
104+ );
105+ }
106+
107+ public function testSanitizeBoolCoercesAnyTruthyInputToOneOrZero(): void
108+ {
109+ $field = ['type' => 'bool'];
110+ $this->assertSame('1', \znote_plugin_settings_sanitize_field($field, '1'));
111+ $this->assertSame('1', \znote_plugin_settings_sanitize_field($field, 'on'));
112+ $this->assertSame('0', \znote_plugin_settings_sanitize_field($field, null));
113+ $this->assertSame('0', \znote_plugin_settings_sanitize_field($field, '0'));
114+ }
115+
116+ public function testSanitizeIntRejectsNonNumericInput(): void
117+ {
118+ $field = ['type' => 'int', 'min' => null, 'max' => null];
119+ $this->assertNull(\znote_plugin_settings_sanitize_field($field, 'not a number'));
120+ $this->assertNull(\znote_plugin_settings_sanitize_field($field, '12; DROP TABLE accounts'));
121+ }
122+
123+ public function testSanitizeIntClampsToMinAndMax(): void
124+ {
125+ $field = ['type' => 'int', 'min' => 1, 'max' => 10];
126+ $this->assertSame('1', \znote_plugin_settings_sanitize_field($field, '-5'));
127+ $this->assertSame('10', \znote_plugin_settings_sanitize_field($field, '500'));
128+ $this->assertSame('5', \znote_plugin_settings_sanitize_field($field, '5'));
129+ }
130+
131+ public function testSanitizeSelectRejectsAValueOutsideItsOptions(): void
132+ {
133+ $field = ['type' => 'select', 'options' => ['a' => 'A', 'b' => 'B']];
134+ $this->assertSame('a', \znote_plugin_settings_sanitize_field($field, 'a'));
135+ $this->assertNull(\znote_plugin_settings_sanitize_field($field, 'injected'));
136+ }
137+
138+ public function testSanitizeChecklistKeepsOnlyKnownOptions(): void
139+ {
140+ $field = ['type' => 'checklist', 'options' => ['a' => 'A', 'b' => 'B', 'c' => 'C']];
141+ $this->assertSame('a,c', \znote_plugin_settings_sanitize_field($field, ['a', 'c', 'not_an_option']));
142+ }
143+
144+ public function testSanitizeTextRejectsNonScalarInput(): void
145+ {
146+ $field = ['type' => 'text'];
147+ $this->assertNull(\znote_plugin_settings_sanitize_field($field, ['array', 'not', 'scalar']));
148+ $this->assertSame('hello', \znote_plugin_settings_sanitize_field($field, 'hello'));
149+ }
150+}
A tests/Security/ServerAdapterTest.php +107-0 View file
@@ -0,0 +1,107 @@
1+<?php
2+
3+declare(strict_types=1);
4+
5+namespace ZnoteX\Tests\Security;
6+
7+use PHPUnit\Framework\TestCase;
8+
9+require_once dirname(__DIR__, 2) . '/engine/adapter/ServerAdapterInterface.php';
10+require_once dirname(__DIR__, 2) . '/engine/adapter/TFSAdapter.php';
11+require_once dirname(__DIR__, 2) . '/engine/adapter/CanaryAdapter.php';
12+require_once dirname(__DIR__, 2) . '/engine/adapter/OtHireAdapter.php';
13+require_once dirname(__DIR__, 2) . '/engine/adapter/BlackTekAdapter.php';
14+require_once dirname(__DIR__, 2) . '/engine/adapter/factory.php';
15+
16+final class ServerAdapterTest extends TestCase
17+{
18+ protected function setUp(): void
19+ {
20+ $GLOBALS['config'] = [
21+ 'ServerEngine' => 'TFS_10',
22+ 'ServerEngineReal' => 'TFS_10',
23+ ];
24+ }
25+
26+ /** @dataProvider engineProvider */
27+ public function testFactoryPicksTheRightAdapterClass(string $engine, string $expectedClass): void
28+ {
29+ $adapter = \znote_server_adapter($engine);
30+ $this->assertInstanceOf($expectedClass, $adapter);
31+ $this->assertSame($engine, $adapter->key());
32+ }
33+
34+ public static function engineProvider(): array
35+ {
36+ return [
37+ 'TFS_02' => ['TFS_02', \TFSAdapter::class],
38+ 'TFS_03' => ['TFS_03', \TFSAdapter::class],
39+ 'TFS_10' => ['TFS_10', \TFSAdapter::class],
40+ 'TFS_16' => ['TFS_16', \TFSAdapter::class],
41+ 'OTHIRE' => ['OTHIRE', \OtHireAdapter::class],
42+ 'CANARY' => ['CANARY', \CanaryAdapter::class],
43+ 'BLACKTEK' => ['BLACKTEK', \BlackTekAdapter::class],
44+ ];
45+ }
46+
47+ public function testFactoryFallsBackToGlobalConfigWhenNoEngineIsGiven(): void
48+ {
49+ $GLOBALS['config']['ServerEngineReal'] = 'OTHIRE';
50+ $adapter = \znote_server_adapter();
51+ $this->assertInstanceOf(\OtHireAdapter::class, $adapter);
52+ }
53+
54+ public function testFactoryCachesOneInstancePerEngine(): void
55+ {
56+ $first = \znote_server_adapter('TFS_10');
57+ $second = \znote_server_adapter('TFS_10');
58+ $this->assertSame($first, $second);
59+ }
60+
61+ public function testLegacyTwoFactorSupportMatchesWhichEnginesHaveAccountsSecret(): void
62+ {
63+ // TFS_10, TFS_16 (normalised to TFS_10) and BlackTek all ship an
64+ // accounts.secret column; TFS_02, TFS_03, Canary and otHire do not.
65+ $this->assertTrue((new \TFSAdapter('TFS_10'))->supportsLegacyTwoFactor());
66+ $this->assertFalse((new \TFSAdapter('TFS_02'))->supportsLegacyTwoFactor());
67+ $this->assertFalse((new \TFSAdapter('TFS_03'))->supportsLegacyTwoFactor());
68+ $this->assertFalse((new \TFSAdapter('TFS_16'))->supportsLegacyTwoFactor());
69+ $this->assertFalse((new \CanaryAdapter())->supportsLegacyTwoFactor());
70+ $this->assertFalse((new \OtHireAdapter())->supportsLegacyTwoFactor());
71+ $this->assertTrue((new \BlackTekAdapter())->supportsLegacyTwoFactor());
72+ }
73+
74+ public function testOthireIdentifiesAccountsByIdNotName(): void
75+ {
76+ $adapter = new \OtHireAdapter();
77+ $this->assertSame('id', $adapter->accountIdentityColumn());
78+ $this->assertSame('`a`.`id`', $adapter->accountDisplayColumn());
79+ }
80+
81+ /** @dataProvider normalizedEngineProvider */
82+ public function testNormalizedEngineMatchesTheSchemaAnAdapterActuallyRuns(string $realEngine, string $expected): void
83+ {
84+ $this->assertSame($expected, \znote_server_adapter($realEngine)->normalizedEngine());
85+ }
86+
87+ public static function normalizedEngineProvider(): array
88+ {
89+ return [
90+ 'TFS_02 stays TFS_02' => ['TFS_02', 'TFS_02'],
91+ 'TFS_03 stays TFS_03' => ['TFS_03', 'TFS_03'],
92+ 'TFS_10 stays TFS_10' => ['TFS_10', 'TFS_10'],
93+ 'TFS_16 normalises to TFS_10' => ['TFS_16', 'TFS_10'],
94+ 'Canary normalises to TFS_10' => ['CANARY', 'TFS_10'],
95+ 'BlackTek normalises to TFS_10' => ['BLACKTEK', 'TFS_10'],
96+ 'otHire stays OTHIRE' => ['OTHIRE', 'OTHIRE'],
97+ ];
98+ }
99+
100+ public function testEveryOtherAdapterIdentifiesAccountsByName(): void
101+ {
102+ foreach ([new \TFSAdapter('TFS_10'), new \CanaryAdapter(), new \BlackTekAdapter()] as $adapter) {
103+ $this->assertSame('name', $adapter->accountIdentityColumn());
104+ $this->assertSame('`a`.`name`', $adapter->accountDisplayColumn());
105+ }
106+ }
107+}
A tests/Security/TotpCompatibilityTest.php +65-0 View file
@@ -0,0 +1,65 @@
1+<?php
2+
3+declare(strict_types=1);
4+
5+namespace ZnoteX\Tests\Security;
6+
7+use PHPUnit\Framework\TestCase;
8+
9+require_once dirname(__DIR__, 2) . '/engine/function/rfc6238.php';
10+
11+final class TotpCompatibilityTest extends TestCase
12+{
13+ public function testBase32DecodesRfc4648Vectors(): void
14+ {
15+ $vectors = [
16+ 'MY======' => 'f',
17+ 'MZXQ====' => 'fo',
18+ 'MZXW6===' => 'foo',
19+ 'MZXW6YQ=' => 'foob',
20+ 'MZXW6YTB' => 'fooba',
21+ 'MZXW6YTBOI======' => 'foobar',
22+ ];
23+
24+ foreach ($vectors as $encoded => $plain) {
25+ $this->assertSame($plain, \Base32Static::decode($encoded));
26+ }
27+ }
28+
29+ public function testTwentyCharacterUnpaddedAuthenticatorSecretRoundTrips(): void
30+ {
31+ $plain = 'Hello World!';
32+ $secret = \Base32Static::encode($plain, false);
33+
34+ $this->assertSame(20, strlen($secret));
35+ $this->assertSame($plain, \Base32Static::decode($secret));
36+ }
37+
38+ public function testLowercaseUnpaddedSecretsAreAccepted(): void
39+ {
40+ $this->assertSame('Hello World!', \Base32Static::decode('jbswy3dpeblw64tmmqqq'));
41+ }
42+
43+ public function testMalformedBase32IsRejected(): void
44+ {
45+ $this->assertFalse(\Base32Static::decode('INVALID-SECRET'));
46+ $this->assertFalse(\Base32Static::decode('M=ZXW6==='));
47+ }
48+
49+ public function testAuthenticatorUriDeclaresPortableTotpParameters(): void
50+ {
51+ $url = \TokenAuth6238::getBarCodeUrl('account', 'localhost', 'JBSWY3DPEHPK3PXP', 'ZnoteX');
52+ parse_str((string)parse_url($url, PHP_URL_QUERY), $qrQuery);
53+ $this->assertArrayHasKey('data', $qrQuery);
54+
55+ $otpauth = (string)$qrQuery['data'];
56+ $this->assertStringStartsWith('otpauth://totp/account%40localhost?', $otpauth);
57+ parse_str((string)parse_url($otpauth, PHP_URL_QUERY), $totpQuery);
58+
59+ $this->assertSame('JBSWY3DPEHPK3PXP', $totpQuery['secret'] ?? null);
60+ $this->assertSame('ZnoteX', $totpQuery['issuer'] ?? null);
61+ $this->assertSame('SHA1', $totpQuery['algorithm'] ?? null);
62+ $this->assertSame('6', $totpQuery['digits'] ?? null);
63+ $this->assertSame('30', $totpQuery['period'] ?? null);
64+ }
65+}
A tests/Security/TwoFactorV2Test.php +98-0 View file
@@ -0,0 +1,98 @@
1+<?php
2+
3+declare(strict_types=1);
4+
5+namespace ZnoteX\Tests\Security;
6+
7+use PHPUnit\Framework\TestCase;
8+
9+require_once dirname(__DIR__, 2) . '/engine/function/rfc6238.php';
10+require_once dirname(__DIR__, 2) . '/engine/function/twofa2.php';
11+
12+final class TwoFactorV2Test extends TestCase
13+{
14+ protected function setUp(): void
15+ {
16+ $GLOBALS['config'] = [
17+ 'session_prefix' => 'znote_',
18+ 'twoFactorV2' => [],
19+ ];
20+ }
21+
22+ public function testConfigFallsBackToSafeDefaultsWhenNothingIsSet(): void
23+ {
24+ $cfg = \znote2fa_v2_config();
25+
26+ $this->assertFalse($cfg['enabled']);
27+ $this->assertTrue($cfg['email_otp_enabled']);
28+ $this->assertFalse($cfg['force_admins']);
29+ $this->assertSame(10, $cfg['recovery_codes_count']);
30+ $this->assertSame(30, $cfg['trusted_device_days']);
31+ }
32+
33+ public function testConfigHonoursExplicitValues(): void
34+ {
35+ $GLOBALS['config']['twoFactorV2'] = [
36+ 'enabled' => true,
37+ 'email_otp_enabled' => false,
38+ 'force_admins' => true,
39+ 'recovery_codes_count' => 0,
40+ 'trusted_device_days' => -5,
41+ ];
42+
43+ $cfg = \znote2fa_v2_config();
44+
45+ $this->assertTrue($cfg['enabled']);
46+ $this->assertFalse($cfg['email_otp_enabled']);
47+ $this->assertTrue($cfg['force_admins']);
48+ $this->assertSame(1, $cfg['recovery_codes_count']);
49+ $this->assertSame(0, $cfg['trusted_device_days']);
50+ }
51+
52+ public function testEnabledMirrorsTheConfig(): void
53+ {
54+ $this->assertFalse(\znote2fa_v2_enabled());
55+ $GLOBALS['config']['twoFactorV2']['enabled'] = true;
56+ $this->assertTrue(\znote2fa_v2_enabled());
57+ }
58+
59+ public function testAdminEnforcementDoesNotCreateAnImpossibleLoginChallenge(): void
60+ {
61+ $this->assertFalse(\znote2fa_login_challenge_required(true, ['any_enabled' => false]));
62+ $this->assertTrue(\znote2fa_login_challenge_required(true, ['any_enabled' => true]));
63+ $this->assertFalse(\znote2fa_login_challenge_required(false, ['any_enabled' => true]));
64+ }
65+
66+ public function testTrustedCookieNameFollowsTheSessionPrefix(): void
67+ {
68+ $this->assertSame('znote_2fa_trust', \znote2fa_trusted_cookie_name());
69+
70+ $GLOBALS['config']['session_prefix'] = 'myserver_';
71+ $this->assertSame('myserver_2fa_trust', \znote2fa_trusted_cookie_name());
72+ }
73+
74+ public function testRecoveryFormatUppercasesAndStripsSeparators(): void
75+ {
76+ $this->assertSame('ABCDE12345', \znote2fa_recovery_format('abcde-12345'));
77+ $this->assertSame('ABCDE12345', \znote2fa_recovery_format(' abcde 12345 '));
78+ }
79+
80+ public function testRecoveryFormatRejectsPunctuationOnly(): void
81+ {
82+ $this->assertSame('', \znote2fa_recovery_format('---'));
83+ }
84+
85+ public function testRecoveryDecodeToleratesMissingOrMalformedStorage(): void
86+ {
87+ $this->assertSame([], \znote2fa_recovery_decode(null));
88+ $this->assertSame([], \znote2fa_recovery_decode(''));
89+ $this->assertSame([], \znote2fa_recovery_decode('not json'));
90+ $this->assertSame(['a', 'b'], \znote2fa_recovery_decode('["a","b"]'));
91+ }
92+
93+ public function testVerifyLoginInputRejectsEmptyInput(): void
94+ {
95+ $this->assertFalse(\znote2fa_verify_login_input(1, ''));
96+ $this->assertFalse(\znote2fa_verify_login_input(1, ' '));
97+ }
98+}
A tests/Security/UpdateInstallSecurityTest.php +265-0 View file
@@ -0,0 +1,265 @@
1+<?php
2+
3+declare(strict_types=1);
4+
5+namespace ZnoteX\Tests\Security;
6+
7+use PHPUnit\Framework\TestCase;
8+use ZipArchive;
9+
10+final class UpdateInstallSecurityTest extends TestCase
11+{
12+ private static string $realPublicKeyFile;
13+ private static string $realPublicKeyBackup;
14+
15+ public static function setUpBeforeClass(): void
16+ {
17+ // znote_update_verify_manifest() always reads the real production
18+ // public key from disk. To exercise that real function (rather than
19+ // re-implement its logic in the test) we swap that file for our
20+ // disposable test key for the duration of this class, and restore
21+ // the real one in tearDownAfterClass() even if a test fails.
22+ self::$realPublicKeyFile = dirname(__DIR__, 2) . '/engine/update-public.pem';
23+ self::$realPublicKeyBackup = file_get_contents(self::$realPublicKeyFile);
24+ file_put_contents(
25+ self::$realPublicKeyFile,
26+ file_get_contents(__DIR__ . '/../fixtures/test-update-public.pem')
27+ );
28+ }
29+
30+ public static function tearDownAfterClass(): void
31+ {
32+ file_put_contents(self::$realPublicKeyFile, self::$realPublicKeyBackup);
33+ }
34+
35+ /** @dataProvider traversalPathProvider */
36+ public function testPathTraversalIsRejected(string $malicious): void
37+ {
38+ $this->assertSame('', \znote_update_path($malicious));
39+ }
40+
41+ public static function traversalPathProvider(): array
42+ {
43+ return [
44+ 'parent directory' => ['../../../etc/passwd'],
45+ 'nested traversal' => ['engine/../../config.local.php'],
46+ 'bare dot dot' => ['..'],
47+ 'dot segment' => ['engine/./config.php'],
48+ 'null byte' => ["engine/config\0.php"],
49+ 'colon (windows drive / ADS)' => ['C:/Windows/System32'],
50+ 'wildcard' => ['engine/*.php'],
51+ 'empty segment' => ['engine//config.php'],
52+ ];
53+ }
54+
55+ public function testAnOrdinaryPackagedPathIsAccepted(): void
56+ {
57+ $this->assertSame('engine/function/general.php', \znote_update_path('engine/function/general.php'));
58+ }
59+
60+ public function testBackslashesAreNormalisedToForwardSlashes(): void
61+ {
62+ $this->assertSame('engine/function/general.php', \znote_update_path('engine\\function\\general.php'));
63+ }
64+
65+ /** @dataProvider protectedPathProvider */
66+ public function testProtectedPathsCanNeverBeOverwrittenByAnUpdate(string $path): void
67+ {
68+ $this->assertTrue(\znote_update_protected($path));
69+ }
70+
71+ public static function protectedPathProvider(): array
72+ {
73+ return [
74+ 'config.php' => ['config.php'],
75+ 'config.local.php' => ['config.local.php'],
76+ 'a plugin file' => ['plugins/shop_coupons/plugin.php'],
77+ 'a theme file' => ['layouts/bloodfang/shells/default.php'],
78+ 'the update storage itself' => ['engine/update/installed.json'],
79+ 'uploaded theme images' => ['engine/img/theme/banner.png'],
80+ 'the installer' => ['install/index.php'],
81+ 'a release artifact' => ['release/2.0.3/znotex-core-2.0.3.zip'],
82+ 'git internals' => ['.git/config'],
83+ 'github workflows' => ['.github/workflows/ci.yml'],
84+ ];
85+ }
86+
87+ public function testAnOrdinaryCoreFileIsNotProtected(): void
88+ {
89+ $this->assertFalse(\znote_update_protected('engine/function/general.php'));
90+ }
91+
92+ public function testManifestSignatureVerification(): void
93+ {
94+ [$manifest, $json, $signature] = $this->signedManifest();
95+
96+ $result = \znote_update_verify_manifest($json, $signature);
97+ $this->assertTrue($result['ok']);
98+ $this->assertSame($manifest['version'], $result['manifest']['version']);
99+ }
100+
101+ public function testTamperedManifestBodyFailsVerificationEvenWithAValidSignature(): void
102+ {
103+ [, $json, $signature] = $this->signedManifest();
104+
105+ $tampered = $json . ' ';
106+ $result = \znote_update_verify_manifest($tampered, $signature);
107+ $this->assertFalse($result['ok']);
108+ }
109+
110+ public function testForgedSignatureIsRejected(): void
111+ {
112+ [, $json] = $this->signedManifest();
113+
114+ $result = \znote_update_verify_manifest($json, base64_encode(str_repeat('x', 256)));
115+ $this->assertFalse($result['ok']);
116+ }
117+
118+ public function testManifestWithAnInvalidPackageNameIsRejected(): void
119+ {
120+ [$manifest] = $this->signedManifest();
121+ $manifest['package']['name'] = '../evil.zip';
122+ [$json, $signature] = $this->sign($manifest);
123+
124+ $result = \znote_update_verify_manifest($json, $signature);
125+ $this->assertFalse($result['ok']);
126+ }
127+
128+ public function testManifestListingAProtectedFileIsRejected(): void
129+ {
130+ // The signature check itself does not know about protected paths -
131+ // that is enforced separately by znote_update_extract(). This test
132+ // documents that boundary: a manifest can list any path shape as far
133+ // as signature verification is concerned, but znote_update_path()
134+ // still normalises it, so a raw ".." entry is caught here already.
135+ [$manifest] = $this->signedManifest();
136+ $manifest['files'] = ['../../config.local.php' => str_repeat('a', 64)];
137+ [$json, $signature] = $this->sign($manifest);
138+
139+ $result = \znote_update_verify_manifest($json, $signature);
140+ $this->assertFalse($result['ok']);
141+ }
142+
143+ public function testExtractRejectsAFileNotListedInTheSignedManifest(): void
144+ {
145+ $zipFile = $this->buildZip(['engine/function/general.php' => 'safe contents']);
146+ $destination = sys_get_temp_dir() . '/znotex-test-extract-' . bin2hex(random_bytes(6));
147+
148+ try {
149+ $files = []; // Nothing is listed as signed.
150+ $result = \znote_update_extract($zipFile, $files, $destination);
151+ $this->assertFalse($result['ok']);
152+ $this->assertStringContainsString('unexpected or protected', $result['error']);
153+ } finally {
154+ @unlink($zipFile);
155+ $this->removeTree($destination);
156+ }
157+ }
158+
159+ public function testExtractRejectsAProtectedFileEvenIfItsHashMatches(): void
160+ {
161+ $content = "<?php \$config['sqlPassword'] = 'stolen';";
162+ $hash = hash('sha256', $content);
163+ $zipFile = $this->buildZip(['config.local.php' => $content]);
164+ $destination = sys_get_temp_dir() . '/znotex-test-extract-' . bin2hex(random_bytes(6));
165+
166+ try {
167+ $result = \znote_update_extract($zipFile, ['config.local.php' => $hash], $destination);
168+ $this->assertFalse($result['ok']);
169+ $this->assertStringContainsString('unexpected or protected', $result['error']);
170+ } finally {
171+ @unlink($zipFile);
172+ $this->removeTree($destination);
173+ }
174+ }
175+
176+ public function testExtractRejectsAFileWhoseContentDoesNotMatchItsSignedHash(): void
177+ {
178+ $zipFile = $this->buildZip(['engine/function/general.php' => 'tampered contents']);
179+ $destination = sys_get_temp_dir() . '/znotex-test-extract-' . bin2hex(random_bytes(6));
180+
181+ try {
182+ $wrongHash = hash('sha256', 'original untampered contents');
183+ $result = \znote_update_extract($zipFile, ['engine/function/general.php' => $wrongHash], $destination);
184+ $this->assertFalse($result['ok']);
185+ $this->assertStringContainsString('checksum validation', $result['error']);
186+ } finally {
187+ @unlink($zipFile);
188+ $this->removeTree($destination);
189+ }
190+ }
191+
192+ public function testExtractAcceptsAFileThatMatchesItsSignedHash(): void
193+ {
194+ $content = 'genuine contents';
195+ $hash = hash('sha256', $content);
196+ $zipFile = $this->buildZip(['engine/function/example.php' => $content]);
197+ $destination = sys_get_temp_dir() . '/znotex-test-extract-' . bin2hex(random_bytes(6));
198+
199+ try {
200+ $result = \znote_update_extract($zipFile, ['engine/function/example.php' => $hash], $destination);
201+ $this->assertTrue($result['ok']);
202+ $this->assertSame($content, file_get_contents($destination . '/engine/function/example.php'));
203+ } finally {
204+ @unlink($zipFile);
205+ $this->removeTree($destination);
206+ }
207+ }
208+
209+ /** @return array{0: array, 1: string, 2: string} */
210+ private function signedManifest(): array
211+ {
212+ $manifest = [
213+ 'schema' => 1,
214+ 'version' => '9.9.9',
215+ 'package' => [
216+ 'name' => 'znotex-core-9.9.9.zip',
217+ 'sha256' => str_repeat('a', 64),
218+ 'size' => 123,
219+ ],
220+ 'files' => [
221+ 'engine/function/general.php' => str_repeat('b', 64),
222+ ],
223+ ];
224+ [$json, $signature] = $this->sign($manifest);
225+
226+ return [$manifest, $json, $signature];
227+ }
228+
229+ private function sign(array $manifest): array
230+ {
231+ $json = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
232+ $privateKey = file_get_contents(__DIR__ . '/../fixtures/test-update-private.pem');
233+ openssl_sign($json, $signature, $privateKey, OPENSSL_ALGO_SHA256);
234+
235+ return [$json, base64_encode($signature) . "\n"];
236+ }
237+
238+ private function buildZip(array $files): string
239+ {
240+ $path = sys_get_temp_dir() . '/znotex-test-' . bin2hex(random_bytes(6)) . '.zip';
241+ $zip = new ZipArchive();
242+ $zip->open($path, ZipArchive::CREATE);
243+ foreach ($files as $name => $content) {
244+ $zip->addFromString($name, $content);
245+ }
246+ $zip->close();
247+
248+ return $path;
249+ }
250+
251+ private function removeTree(string $path): void
252+ {
253+ if (!is_dir($path)) {
254+ return;
255+ }
256+ $iterator = new \RecursiveIteratorIterator(
257+ new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
258+ \RecursiveIteratorIterator::CHILD_FIRST
259+ );
260+ foreach ($iterator as $item) {
261+ $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
262+ }
263+ rmdir($path);
264+ }
265+}
A tools/build-release.php +149-0 View file
@@ -0,0 +1,149 @@
1+<?php
2+
3+if (PHP_SAPI !== 'cli') {
4+ http_response_code(403);
5+ exit(1);
6+}
7+
8+$root = dirname(__DIR__);
9+$metadataFile = __DIR__ . '/release-metadata.json';
10+$metadata = json_decode((string)file_get_contents($metadataFile), true);
11+$version = (string)($argv[1] ?? ($metadata['version'] ?? ''));
12+$outputRoot = rtrim((string)($argv[2] ?? ($root . '/release')), '/\\');
13+$current = (string)require $root . '/engine/version.php';
14+
15+if (!is_array($metadata) || $version === '' || $version !== (string)($metadata['version'] ?? '') || $version !== $current) {
16+ fwrite(STDERR, "The version must match tools/release-metadata.json and engine/version.php.\n");
17+ exit(1);
18+}
19+if (!class_exists('ZipArchive') || !function_exists('openssl_sign')) {
20+ fwrite(STDERR, "PHP Zip and OpenSSL are required.\n");
21+ exit(1);
22+}
23+
24+$signingDirectory = $outputRoot . '/.signing';
25+$privateKeyFile = $signingDirectory . '/private.pem';
26+$publicKeyFile = $root . '/engine/update-public.pem';
27+if (!is_dir($signingDirectory) && !mkdir($signingDirectory, 0700, true) && !is_dir($signingDirectory)) {
28+ fwrite(STDERR, "The signing directory cannot be created.\n");
29+ exit(1);
30+}
31+
32+if (!is_file($privateKeyFile)) {
33+ fwrite(STDERR, "The private signing key is missing. Run tools/generate-release-key.ps1 once.\n");
34+ exit(1);
35+}
36+
37+$privatePem = (string)file_get_contents($privateKeyFile);
38+$key = openssl_pkey_get_private($privatePem);
39+$details = $key !== false ? openssl_pkey_get_details($key) : false;
40+if ($key === false || !is_array($details) || !is_file($publicKeyFile) || trim((string)file_get_contents($publicKeyFile)) !== trim((string)$details['key'])) {
41+ fwrite(STDERR, "The private key does not match engine/update-public.pem.\n");
42+ exit(1);
43+}
44+
45+$releaseDirectory = $outputRoot . '/' . $version;
46+if (!is_dir($releaseDirectory) && !mkdir($releaseDirectory, 0755, true) && !is_dir($releaseDirectory)) {
47+ fwrite(STDERR, "The release directory cannot be created.\n");
48+ exit(1);
49+}
50+if (is_file(__DIR__ . '/RELEASE.md')) {
51+ copy(__DIR__ . '/RELEASE.md', $outputRoot . '/README.md');
52+}
53+
54+$allowedDirectories = array('admin', 'api', 'assets', 'engine', 'locale', 'SQL', 'vendor');
55+$excludedPrefixes = array('engine/cache/', 'engine/img/theme/', 'engine/update/');
56+$files = array();
57+
58+$accept = static function (string $relative) use ($excludedPrefixes): bool {
59+ $relative = str_replace('\\', '/', $relative);
60+ foreach ($excludedPrefixes as $prefix) {
61+ if (str_starts_with($relative, $prefix)) {
62+ return false;
63+ }
64+ }
65+ return !str_ends_with($relative, '.znote-update');
66+};
67+
68+foreach ($allowedDirectories as $directory) {
69+ $base = $root . '/' . $directory;
70+ if (!is_dir($base)) {
71+ continue;
72+ }
73+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS));
74+ foreach ($iterator as $item) {
75+ if (!$item->isFile() || $item->isLink()) {
76+ continue;
77+ }
78+ $relative = str_replace('\\', '/', substr($item->getPathname(), strlen($root) + 1));
79+ if ($accept($relative)) {
80+ $files[$relative] = $item->getPathname();
81+ }
82+ }
83+}
84+
85+foreach (glob($root . '/*.php') ?: array() as $file) {
86+ $name = basename($file);
87+ if (!in_array(strtolower($name), array('config.php', 'config.local.php'), true)) {
88+ $files[$name] = $file;
89+ }
90+}
91+foreach (array('.htaccess', 'composer.json', 'composer.lock', 'LICENSE', 'README.md', 'config.countries.php') as $name) {
92+ if (is_file($root . '/' . $name)) {
93+ $files[$name] = $root . '/' . $name;
94+ }
95+}
96+ksort($files);
97+
98+$packageName = 'znotex-core-' . $version . '.zip';
99+$packageFile = $releaseDirectory . '/' . $packageName;
100+$zip = new ZipArchive();
101+if ($zip->open($packageFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
102+ fwrite(STDERR, "The release ZIP cannot be created.\n");
103+ exit(1);
104+}
105+$hashes = array();
106+foreach ($files as $relative => $file) {
107+ if (!$zip->addFile($file, $relative)) {
108+ $zip->close();
109+ fwrite(STDERR, "A file cannot be added: " . $relative . "\n");
110+ exit(1);
111+ }
112+ $hashes[$relative] = hash_file('sha256', $file);
113+}
114+$zip->close();
115+
116+$manifest = $metadata;
117+$manifest['schema'] = 1;
118+$manifest['project'] = 'ZnoteX';
119+$manifest['version'] = $version;
120+$manifest['published_at'] = gmdate(DATE_ATOM);
121+$manifest['package'] = array(
122+ 'name' => $packageName,
123+ 'sha256' => hash_file('sha256', $packageFile),
124+ 'size' => filesize($packageFile),
125+);
126+$manifest['installation'] = array(
127+ 'mode' => 'signed-core-update',
128+ 'backup' => true,
129+ 'rollback' => 'files',
130+ 'database_policy' => 'expand-only'
131+);
132+$manifest['protected_paths'] = array('config.php', 'config.local.php', 'plugins/', 'layouts/', 'engine/cache/', 'engine/img/theme/', 'engine/update/', 'install/', 'release/');
133+$manifest['links'] = array(
134+ 'repository' => 'https://github.com/Open-Games-Community/ZnoteX',
135+ 'release' => 'https://github.com/Open-Games-Community/ZnoteX/releases/tag/v' . $version
136+);
137+$manifest['files'] = $hashes;
138+
139+$json = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
140+if (!openssl_sign($json, $signature, $key, OPENSSL_ALGO_SHA256)) {
141+ fwrite(STDERR, "The release manifest cannot be signed.\n");
142+ exit(1);
143+}
144+file_put_contents($releaseDirectory . '/update.json', $json, LOCK_EX);
145+file_put_contents($releaseDirectory . '/update.json.sig', base64_encode($signature) . "\n", LOCK_EX);
146+
147+echo $releaseDirectory . PHP_EOL;
148+echo $packageName . PHP_EOL;
149+echo count($hashes) . " files\n";
A tools/generate-release-key.php +33-0 View file
@@ -0,0 +1,33 @@
1+<?php
2+
3+if (PHP_SAPI !== 'cli') {
4+ exit(1);
5+}
6+
7+$outputRoot = rtrim((string)($argv[1] ?? ''), '/\\');
8+$publicKeyFile = (string)($argv[2] ?? '');
9+$privateKeyFile = $outputRoot . '/.signing/private.pem';
10+$options = array(
11+ 'config' => __DIR__ . '/openssl.cnf',
12+ 'private_key_bits' => 3072,
13+ 'private_key_type' => OPENSSL_KEYTYPE_RSA,
14+);
15+
16+if ($outputRoot === '' || $publicKeyFile === '' || (is_file($privateKeyFile) && filesize($privateKeyFile) > 0)) {
17+ fwrite(STDERR, "Invalid key destination or an existing private key.\n");
18+ exit(1);
19+}
20+
21+$key = openssl_pkey_new($options);
22+if ($key === false || !openssl_pkey_export($key, $privatePem, null, $options)) {
23+ fwrite(STDERR, "OpenSSL could not generate the private key.\n");
24+ exit(1);
25+}
26+$details = openssl_pkey_get_details($key);
27+if (!is_array($details) || file_put_contents($privateKeyFile, $privatePem, LOCK_EX) === false || file_put_contents($publicKeyFile, $details['key'], LOCK_EX) === false) {
28+ fwrite(STDERR, "The signing keys could not be saved.\n");
29+ exit(1);
30+}
31+
32+echo $privateKeyFile . PHP_EOL;
33+echo $publicKeyFile . PHP_EOL;
A tools/generate-release-key.ps1 +27-0 View file
@@ -0,0 +1,27 @@
1+param(
2+ [Parameter(Mandatory = $true)][string]$OutputRoot,
3+ [Parameter(Mandatory = $true)][string]$PublicKeyFile,
4+ [string]$PhpBinary = 'php'
5+)
6+
7+$signingDirectory = Join-Path $OutputRoot '.signing'
8+$privateKeyFile = Join-Path $signingDirectory 'private.pem'
9+if ((Test-Path -LiteralPath $privateKeyFile) -and (Get-Item -LiteralPath $privateKeyFile).Length -gt 0) {
10+ throw 'The private signing key already exists.'
11+}
12+
13+New-Item -ItemType Directory -Path $signingDirectory -Force | Out-Null
14+$randomFile = Join-Path $signingDirectory '.rnd'
15+$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
16+$bytes = New-Object byte[] 256
17+$rng.GetBytes($bytes)
18+[System.IO.File]::WriteAllBytes($randomFile, $bytes)
19+$rng.Dispose()
20+
21+$env:OPENSSL_CONF = Join-Path $PSScriptRoot 'openssl.cnf'
22+$env:RANDFILE = $randomFile
23+& $PhpBinary (Join-Path $PSScriptRoot 'generate-release-key.php') $OutputRoot $PublicKeyFile
24+if ($LASTEXITCODE -ne 0) {
25+ throw 'The release signing key could not be generated.'
26+}
27+Remove-Item -LiteralPath $randomFile -Force
A tools/openssl.cnf +10-0 View file
@@ -0,0 +1,10 @@
1+openssl_conf = openssl_init
2+
3+[openssl_init]
4+providers = provider_sect
5+
6+[provider_sect]
7+default = default_sect
8+
9+[default_sect]
10+activate = 1
A tools/release-metadata.json +29-0 View file
@@ -0,0 +1,29 @@
1+{
2+ "version": "2.0.5",
3+ "channel": "stable",
4+ "title": "ZnoteX 2.0.5",
5+ "summary": "BBCode paste fix, a plugin-page CSS crash fix, an overhauled default theme, and a more useful admin dashboard/analytics/plugins experience.",
6+ "description": "This release fixes a News/BBCode bug where pasted images lost their size formatting, and a fatal error that made some installed plugins' admin pages render completely unstyled (white, no CSS). It also modernizes the default theme end to end - account pages, character management, guilds, downloads, highscores, market, support/helpdesk and more all move to a consistent, theme-adaptive card/table/button design with no hardcoded colors, plus a new dedicated character-management page (replacing the old two-dropdown editor) and a new Houses page for the default theme. On the admin side, the Dashboard gains two new activity boxes, Analytics' \"Top countries\" no longer misaligns rows depending on which flag icons happen to exist, plugin updates now report the real database error instead of a message cut off at 60 characters (and no longer fail on a stray file BOM), and long admin error messages wrap instead of being clipped.",
7+ "highlights": [
8+ "Fixed: pasting an image into a News post via the BBCode editor lost its explicit width/height, which could resize or break its layout - image dimensions are now preserved on paste.",
9+ "Fixed: some installed plugins' admin pages rendered as a blank white page with no CSS, caused by a missing acp_color_field() helper the plugin settings framework depended on.",
10+ "Fixed: plugin install/update could fail with a confusing, truncated error (\"...first: CREATE TABLE...\") when the plugin's install.sql file started with a UTF-8 BOM; the BOM is now stripped automatically and the full failing statement plus the real database error is shown instead of a 60-character snippet.",
11+ "Fixed: long admin panel error/flash messages (e.g. a failed plugin update) were visually clipped instead of wrapping onto multiple lines.",
12+ "Fixed: the Analytics \"Top countries\" list could look misaligned because it reused the site-language flag icons (only 5 exist: de/es/pl/pt/uk); every country now gets a same-width flag emoji instead, so rows always line up regardless of country.",
13+ "Default theme (motor) overhaul: My Account, Character List, Settings, Change Password, Create Character, Downloads, Who's Online, Highscores, Guilds/Top Guilds/Latest Deaths/Kill Statistics/Bans, Server Information, Creatures, Monster Loot, Spells, Item Market, Support, Contact, Vote for Us, Credits and the Helpdesk pages all move to a consistent card/title-banner/table styling using the theme's own CSS variables - no hardcoded colors, so the look follows whatever the theme defines.",
14+ "New: a dedicated Character Management page replaces the old select-a-character/select-an-action dropdown combo on My Account - each character gets a per-row Edit button leading to a page with Change Name, Change Gender, Visibility, Comment and Delete Character as clearly separated actions, including remaining-ticket counts for name/gender changes.",
15+ "New: a proper Houses page for the default theme (town/order/sort filters plus a styled results table) where previously it fell back to unstyled generic markup.",
16+ "The account Guild page (guild list, Create Guild, and the full guild management/overview screen) now uses the same styled cards, tables and forms as the rest of the modernized theme.",
17+ "The Downloads page dropped the outdated \"you need an IP changer\" instructions and reworked the client/tool cards so each one shows a compact Download button instead of one long clickable sentence.",
18+ "New: two Dashboard boxes - \"Recent Shop Purchases\" (from the completed shop-purchase log) and \"Recent Points Purchases\" (from real-money top-ups) - each showing the account, what was bought/paid, and when.",
19+ "The Contact page's placeholder text (shown until an admin sets contact_info) no longer says \"TODO: Edit the contact details here.\" - it now points the admin at the Admin Panel, in all 5 shipped languages."
20+ ],
21+ "warnings": [
22+ "Back up the website and database independently before every production update."
23+ ],
24+ "requirements": {
25+ "php": ">=8.1",
26+ "extensions": ["curl", "json", "openssl", "zip"]
27+ },
28+ "migrations": []
29+}
A tools/verify-release.php +56-0 View file
@@ -0,0 +1,56 @@
1+<?php
2+
3+if (PHP_SAPI !== 'cli') {
4+ exit(1);
5+}
6+
7+$root = dirname(__DIR__);
8+$directory = rtrim((string)($argv[1] ?? ''), '/\\');
9+$jsonFile = $directory . '/update.json';
10+$signatureFile = $directory . '/update.json.sig';
11+if (!is_file($jsonFile) || !is_file($signatureFile) || !is_file($root . '/engine/update-public.pem')) {
12+ fwrite(STDERR, "Release files are missing.\n");
13+ exit(1);
14+}
15+
16+$json = (string)file_get_contents($jsonFile);
17+$signature = base64_decode(trim((string)file_get_contents($signatureFile)), true);
18+$manifest = json_decode($json, true);
19+if ($signature === false || !is_array($manifest) || openssl_verify($json, $signature, (string)file_get_contents($root . '/engine/update-public.pem'), OPENSSL_ALGO_SHA256) !== 1) {
20+ fwrite(STDERR, "The release signature is invalid.\n");
21+ exit(1);
22+}
23+
24+$package = $directory . '/' . (string)$manifest['package']['name'];
25+if (!is_file($package) || hash_file('sha256', $package) !== (string)$manifest['package']['sha256'] || filesize($package) !== (int)$manifest['package']['size']) {
26+ fwrite(STDERR, "The package checksum or size is invalid.\n");
27+ exit(1);
28+}
29+
30+$zip = new ZipArchive();
31+if ($zip->open($package) !== true) {
32+ fwrite(STDERR, "The package cannot be opened.\n");
33+ exit(1);
34+}
35+$seen = array();
36+for ($index = 0; $index < $zip->numFiles; $index++) {
37+ $path = (string)$zip->getNameIndex($index);
38+ if (str_ends_with($path, '/')) {
39+ continue;
40+ }
41+ $data = $zip->getFromIndex($index);
42+ if (!is_string($data) || !isset($manifest['files'][$path]) || hash('sha256', $data) !== (string)$manifest['files'][$path]) {
43+ $zip->close();
44+ fwrite(STDERR, "A packaged file is invalid: " . $path . "\n");
45+ exit(1);
46+ }
47+ $seen[$path] = true;
48+}
49+$zip->close();
50+if (count($seen) !== count($manifest['files'])) {
51+ fwrite(STDERR, "The signed file list does not match the package.\n");
52+ exit(1);
53+}
54+
55+echo "Valid release " . $manifest['version'] . "\n";
56+echo count($seen) . " signed files\n";
A topguilds.php +142-0 View file
@@ -0,0 +1,142 @@
1+<?php require_once 'engine/init.php'; theme_open();
2+
3+ // Cache the results
4+ $cache = new Cache('engine/cache/topGuilds');
5+ if ($cache->hasExpired()) {
6+ $guilds = db()->fetchAll("
7+ SELECT `g`.`id` AS `id`, `g`.`name` AS `name`, COALESCE(`kills`.`frags`, 0) AS `frags`
8+ FROM `guilds` g
9+ LEFT JOIN (
10+ SELECT `gm`.`guild_id` AS `guild_id`, COUNT(*) AS `frags`
11+ FROM `player_deaths` pd
12+ INNER JOIN `players` p ON `p`.`name` = `pd`.`killed_by`
13+ INNER JOIN `guild_membership` gm ON `gm`.`player_id` = `p`.`id`
14+ WHERE `pd`.`unjustified` = 1
15+ GROUP BY `gm`.`guild_id`
16+ ) kills ON `kills`.`guild_id` = `g`.`id`
17+ ORDER BY `frags` DESC, `g`.`name` ASC
18+ LIMIT 0, 10;
19+ ");
20+
21+ $cache->setContent($guilds);
22+ $cache->save();
23+ } else {
24+ $guilds = $cache->load();
25+ }
26+ $count = 1;
27+
28+ function convert_number_to_words($number) {
29+
30+ $hyphen = '-';
31+ $conjunction = ' and ';
32+ $separator = ', ';
33+ $negative= 'negative ';
34+ $decimal = ' point ';
35+ $dictionary = array(
36+ 0 => 'zero',
37+ 1 => 'first',
38+ 2 => 'second',
39+ 3 => 'third',
40+ 4 => 'fourth',
41+ 5 => 'fifth',
42+ 6 => 'sixth',
43+ 7 => 'seventh',
44+ 8 => 'eighth',
45+ 9 => 'ninth',
46+ 10 => 'tenth',
47+ 11 => 'eleventh',
48+ 12 => 'twelve',
49+ 13 => 'thirteen',
50+ 14 => 'fourteen',
51+ 15 => 'fifteen',
52+ 16 => 'sixteen',
53+ 17 => 'seventeen',
54+ 18 => 'eighteen',
55+ 19 => 'nineteen',
56+ 20 => 'twenty',
57+ 30 => 'thirty',
58+ 40 => 'fourty',
59+ 50 => 'fifty',
60+ 60 => 'sixty',
61+ 70 => 'seventy',
62+ 80 => 'eighty',
63+ 90 => 'ninety',
64+ 100 => 'hundred',
65+ 1000 => 'thousand',
66+ 1000000 => 'million',
67+ 1000000000 => 'billion',
68+ 1000000000000 => 'trillion',
69+ 1000000000000000 => 'quadrillion',
70+ 1000000000000000000 => 'quintillion'
71+ );
72+
73+ if (!is_numeric($number)) {
74+ return false;
75+ }
76+
77+ if (($number >= 0 && (int) $number < 0) || (int) $number < 0 - PHP_INT_MAX) {
78+ // overflow
79+ trigger_error(
80+ 'convert_number_to_words only accepts numbers between -' . PHP_INT_MAX . ' and ' . PHP_INT_MAX,
81+ E_USER_WARNING
82+ );
83+ return false;
84+ }
85+
86+ if ($number < 0) {
87+ return $negative . convert_number_to_words(abs($number));
88+ }
89+
90+ $string = $fraction = null;
91+
92+ if (strpos($number, '.') !== false) {
93+ list($number, $fraction) = explode('.', $number);
94+ }
95+
96+ switch (true) {
97+ case $number < 21:
98+ $string = $dictionary[$number];
99+ break;
100+ case $number < 100:
101+ $tens = ((int) ($number / 10)) * 10;
102+ $units = $number % 10;
103+ $string = $dictionary[$tens];
104+ if ($units) {
105+ $string .= $hyphen . $dictionary[$units];
106+ }
107+ break;
108+ case $number < 1000:
109+ $hundreds = $number / 100;
110+ $remainder = $number % 100;
111+ $string = $dictionary[$hundreds] . ' ' . $dictionary[100];
112+ if ($remainder) {
113+ $string .= $conjunction . convert_number_to_words($remainder);
114+ }
115+ break;
116+ default:
117+ $baseUnit = pow(1000, floor(log($number, 1000)));
118+ $numBaseUnits = (int) ($number / $baseUnit);
119+ $remainder = $number % $baseUnit;
120+ $string = convert_number_to_words($numBaseUnits) . ' ' . $dictionary[$baseUnit];
121+ if ($remainder) {
122+ $string .= $remainder < 100 ? $conjunction : $separator;
123+ $string .= convert_number_to_words($remainder);
124+ }
125+ break;
126+ }
127+
128+ if (null !== $fraction && is_numeric($fraction)) {
129+ $string .= $decimal;
130+ $words = array();
131+ foreach (str_split((string) $fraction) as $number) {
132+ $words[] = $dictionary[$number];
133+ }
134+ $string .= implode(' ', $words);
135+ }
136+
137+ return $string;
138+}
139+
140+view('topguilds');
141+
142+theme_close();
A toponline.php +43-0 View file
@@ -0,0 +1,43 @@
1+<?php
2+require_once 'engine/init.php';
3+theme_open();
4+if (!$config['toponline']['enabled']) {
5+echo 'This page has been disabled at config.php.';
6+theme_close();
7+ exit();
8+}
9+$limit = $config['toponline']['limit'];
10+$type = (isset($_GET['type'])) ? getValue($_GET['type'] ?? null) : false;
11+
12+function onlineTimeTotal($value)
13+{
14+ $hours = floor($value / 3600);
15+ $value = $value - $hours * 3600;
16+ $minutes = floor($value / 60);
17+ return '<font color="black">'.$hours.'h '.$minutes.'m</font>';
18+}
19+function hours_and_minutes($value, $color = 1)
20+{
21+ $hours = floor($value / 3600);
22+ $value = $value - $hours * 3600;
23+ $minutes = floor($value / 60);
24+ if($color != 1)
25+ return '<font color="black">'.$hours.'h '.$minutes.'m</font>';
26+ else
27+ if($hours >= 12)
28+ return '<font color="red">'.$hours.'h '.$minutes.'m</font>';
29+ elseif($hours >= 6)
30+ return '<font color="black">'.$hours.'h '.$minutes.'m</font>';
31+ else
32+ return '<font color="green">'.$hours.'h '.$minutes.'m</font>';
33+}
34+if(empty($type))
35+ $znotePlayers = db()->fetchAll('SELECT * FROM `znote_players` AS `z` JOIN `players` AS `p` WHERE `p`.`id`=`z`.`player_id` and `p`.`group_id` < 3 ORDER BY `onlinetimetoday` DESC LIMIT ?', [(int)$limit]);
36+elseif($type == "sum")
37+ $znotePlayers = db()->fetchAll('SELECT * FROM `znote_players` AS `z` JOIN `players` AS `p` WHERE `p`.`id`=`z`.`player_id` and `p`.`group_id` < 3 ORDER BY `z`.`onlinetimeall` DESC LIMIT ?', [(int)$limit]);
38+elseif($type >= 1 && $type <= 4)
39+ $znotePlayers = db()->fetchAll('SELECT * FROM `znote_players` AS `z` JOIN `players` AS `p` WHERE `p`.`id`=`z`.`player_id` and `p`.`group_id` < 3 ORDER BY `onlinetime' . (int) $type . '` DESC LIMIT ?', [(int)$limit]);
40+
41+view('toponline');
42+
43+theme_close();
Top