Migrate Znote AAC to PHP 8.1 β 8.5 (release 2.0.0)
Summary
This PR brings the entire codebase forward from PHP 7.x to modern PHP. It is a prerequisite fordeploying on current hosting, where PHP 7.4 is long gone and even 8.1 is being retired.
The site did not run on PHP 8 before this change β it died with a fatal error on every single
page load. That is fixed here, along with the full set of PHP 8.0β8.5 behaviour changes,
plus several genuine bugs and two security holes found while verifying the migration.
Minimum PHP is now 8.1. PHP 8.0 and older are rejected at startup.
Version bumped 2.0_DEV β 2.0.0.
Critical β the site was broken before this PR
Cannot redeclare elapsedTime() β fatal on every page load
elapsedTime() was declared in both engine/init.php and engine/database/connect.php. Sinceinit.php requires connect.php, PHP fatally errored on every request. Consolidated into a single
guarded definition in connect.php, with the page start time shared via
$GLOBALS['__znote_start_time'] so the timer stays correct for entry points that load connect.php
without init.php (ipn.php, paygol_ipn.php).
Also removed dead timing code in init.php and fixed engine/footer.php, which referenced an
undefined $start.
mysqli silently stopped reporting errors
PHP 8.1 changed the default mysqli_report() mode to MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT.mysqli now throws mysqli_sql_exception instead of returning false, which meant:
- every if ($result === false) check in connect.php was unreachable dead code, and
- any SQL error became an uncaught exception β a blank HTTP 500 with no logging.
regardless of host php.ini) and catches the exception inside the four query wrappers, restoring
the false-returning contract the rest of the codebase depends on while keeping error_log()
diagnostics. Result sets are now freed explicitly.
forum.php began with a blank line before <?php
Output was flushed before ob_start() ran, so headers were already sent and every header()redirect in the forum silently failed. Leading newline stripped.
PHP 8.x compatibility fixes
Fatal in PHP 8.0
- count(): Argument #1 ($value) must be of type Countable|array, false given in
forum_search.php. mysql_select_multi() returns array|false, and count() on false became
a TypeError in PHP 8. This crashed the page whenever a forum search matched nothing β a
trivially reachable path. Added array normalisation at all 7 query sites in that file. Output
behaviour is unchanged (an empty array still renders "No results.").
Deprecated in PHP 8.1, fatal in PHP 9
- "Automatic conversion of false to array is deprecated"in three places:
- engine/init.php β $user_data['premdays'] = ... when user_data() returned false. This
fired on every page for any logged-in session whose account row failed to load, spraying
deprecation notices site-wide. - forum.php β $guildboard[] = ... where $guildboard was initialised to false.
- house.php β $house['points'] = ... on a non-existent house.
- engine/init.php β $user_data['premdays'] = ... when user_data() returned false. This
Deprecated in PHP 8.1
- Passing null to non-nullable internal parameters. Hardened sanitize(), is_admin() and
mysql_znote_escape_string() to be null-safe, and fixed a base_convert(null, ...) in the
Base32 decoder (engine/function/rfc6238.php) that was previously masked with @. - 88 unguarded superglobal reads across 18 files β getValue($_GET['x']) / getValue($_POST['x'])
on keys that may not exist. All now ?? null. These were Undefined array key warnings in PHP 8
(notices in 7.x) and fed null straight into string functions.
Deprecated in PHP 8.4
- Implicitly nullable parameter type β function VerifyPaypalIPN(array $IPN = null) in
ipn.php β ?array.
Swept and confirmed clean
No use of anything removed or deprecated across 8.0β8.5: create_function, each(),money_format, ereg*, split(), mysql_*, FILTER_SANITIZE_STRING, ${} string
interpolation, curly-brace string offsets $str{0}, strftime/gmstrftime, utf8_encode/
utf8_decode, date_sunrise/date_sunset, libxml_disable_entity_loader, E_STRICT,
xml_set_object, mysqli_ping, get_class() with no arguments, (real)/(unset) casts,
swapped implode() argument order, nested ternaries without parentheses, required-after-optional
parameters, and dynamic property creation.
Security fixes
PayGol IPN secret was never actually validated
paygol_ipn.php compared the incoming secret against $paygol['secret'], but config.php definesthe key as secretKey. The missing key evaluated to null, and with no secret in the request
the comparison false != null was itself false β so the 403 guard never triggered and anyone
could forge a shop-points credit callback. Now reads secretKey.
Forum reply did not verify character ownership
forum.php took reply_cid straight from $_POST and used it as the posting character withoutchecking it belonged to the logged-in account. Now validated against the session's own character
list before the post is accepted.
Bug fixes
- guilds.php β $gid = (int)$guild['id']; if ($gid === false) could never be true, because
(int) never yields false. A request for a non-existent guild fell straight through the
redirect guard instead of bouncing to guilds.php. Now checks the row before casting. - house.php β house.php?id=<nonexistent> (reachable by any visitor) walked into a chain of
warnings and a PHP-9 fatal. Now renders a proper "House not found." page. Also guards the bidder
lookup against a missing player row. - api/api.php β UseClass() resolved class files relative to the current working directory,
so any API module invoked directly (rather than from api/) failed to load its class. Now
resolved against __DIR__. Pre-existing upstream bug; api/modules/base/player/test.php was
broken because of it and now works. - shop.php β $_SESSION['shop_session'] is only written at the bottom of the page, so the
first POST read it undefined. Worse, null == null meant a purchase could pass the anti-replay
check. Now requires the session value to actually be set. - pagseguro_ipn.php / pagseguro_retorno.php β a failed or malformed PagSeguro API response
made simplexml_load_string() return false, after which the code read properties off false
and wrote a bogus row / ran an UPDATE with an empty transaction code. Both now bail out. - changepassword.php β the required-fields loop iterated $_POST, so a missing field was
never caught, only an empty one. Rewritten to check the required list directly, with the account
and salt lookups guarded. - helpdesk.php, forum.php, credits.php, monster_loot.php, highscores.php,
myaccount.php β assorted undefined variables and unguarded reads of query results that may
be false.
Documentation
- README rewritten: removed the stale CodeFactor badge and Branch: v2 note, repointed downloads
to Open Games Community, and documented the project as a maintained continuation of the upstream
Znote AAC (unmaintained for ~5 years). - Added explicit Version and Requirements sections with a per-version support table.
- Aligned the version guard in api/api.php with engine/init.php (both were inconsistent β one
said 7.2, the other 8.1).
Verification
All 117 PHP files were linted and executed on five real PHP builds:| PHP | Build | Lint | Execution |
|---|---|---|---|
| 8.1 | 8.1.33 | 117/117 | clean |
| 8.2 | 8.2.33 | 117/117 | clean |
| 8.3 | 8.3.4 | 117/117 | clean |
| 8.4 | 8.4.25 | 117/117 | clean |
| 8.5 | 8.5.10 | 117/117 | clean |
every page, layout fragment, widget, API endpoint and special/ maintenance script across
10 scenarios: guest / logged-in / admin Γ normal / POST-submitted Γ queries-returning-rows /
queries-returning-nothing.
The empty-result-set scenarios are what surfaced most of the bugs above β they exercise the
"row not found" paths that only appear in production when a record is missing or deleted.
Results were byte-identical across all five PHP versions, with zero warnings, deprecations
or exceptions.
Notes for reviewers
- PHPMailer is not vendored. It never was β the README documents it as a separate download
extracted to PHPMailer/. Registration and account-recovery email will fatal until it is added.
Out of scope for this PR, but worth knowing before deploying. - Not fixed, deliberately: helpdesk.php echoes the account name and email into HTML attributes
without escaping. That is an XSS vector, but changing output escaping is a separate concern from
a version migration and deserves its own PR. - Watch for string/number comparison. PHP 8.0 changed == between strings and numbers
(0 == "abc" is now false). No tool can flag this reliably and the codebase compares
DB values loosely in many places, so login and shop flows deserve a manual pass after deploy. - Runtime behaviour was verified against stubbed queries, not a live database. A smoke test against
a real TFS 1.4 schema before going to production is recommended.
Server compatibility documented accurately
The README previously implied per-version TFS support the code does not implement.$config['ServerEngine'] has only four values (OTHIRE, TFS_02, TFS_03, TFS_10) and the
entire TFS 1.x line shares the single TFS_10 path β there is no per-minor-version branching.
Compatibility within 1.x is therefore a schema question, not a code question.
Verified against the real upstream schemas from otland/forgottenserver:
- TFS 1.2 / 1.4 / 1.4.1 / 1.4.2 β supported. 1.4 is the target.
- TFS 1.1 β same code path, expected to work, untested.
- TFS 1.0 β not supported. The TFS_10 path queries players_online and guild_membership,
neither of which exists in 1.0 (it used players.online and players.rank_id). - TFS 1.6 β not supported. Diffing the 1.4 and 1.6 schemas shows players.lastip and
ip_bans.ip changed from int unsigned to varbinary(16). Znote writes both as numeric longs
(sprintf('%u', ip2long($ip))), so IP logging and the IP-ban insert in
engine/function/users.php would write values the server cannot match. 1.6 also adds
player_outfits / player_mounts tables and mount columns on players. - Canary / OTServBR-Global β not supported; no Canary code path exists anywhere.
1.4, 1.4.1, 1.4.2 and 1.6. "TFS 1.5" refers to a community downgrade fork.
TFS 1.6 and Canary support are plausible follow-ups and would mean adding new ServerEngine
values rather than widening TFS_10.
New: TFS 1.6 and Canary support
Two new ServerEngine values were added: TFS_16 and CANARY.Approach
Rather than edit the ~50 existing ServerEngine === 'TFS_10' comparisons (high risk of missingone), engine/init.php and api/api.php record the configured engine in
$config['ServerEngineReal'] and then normalise $config['ServerEngine'] to TFS_10 for the two
new engines. Every existing 1.x branch therefore keeps working untouched, and divergences are
handled by explicit helpers in engine/function/general.php:
serverEngineReal(), engineIsCanary(), engineIsTFS16(), accountField(),
accountFieldList(), houseCol(), houseSelect(), sqlIpWrite(), sqlIpSelect().
TFS 1.6
The only incompatibility is a column type change: players.lastip and ip_bans.ip went fromint unsigned to varbinary(16). Writes now go through INET6_ATON() and reads through
INET_ATON(INET6_NTOA()). Affects character creation (web + API) and the IP-ban path.
Diffing the 1.4 and 1.6 schemas confirmed there are no missing tables or columns otherwise.
Canary / OTServBR-Global
Four divergences, all handled:| Area | Canary | Handling |
|---|---|---|
| Premium | no accounts.premium_ends_at; uses premdays + lastday | accountField() selects (lastday + premdays*86400) AS premium_ends_at; user_account_add_premdays() increments premdays |
| 2FA | no accounts.secret | twoFactorAuthenticator force disabled; accountField('secret') yields NULL AS secret; the ungated 2FA-removal branch in recovery.php is skipped |
| House auctions | internal_bid, bid_end_date, highest_bid, bidder | houseCol() / houseSelect() map and alias them, so the PHP array keys are unchanged |
| Global storage | no world_id column | separate INSERT without it |
rank_id, guildnick) were already unset() on the TFS 1.x path.
Verification
A schema guard was added to the test harness: the stubbed mysqli validates every executedquery against the target server's real schema.sql, which catches dynamically-built SQL that
static analysis cannot see. The full 5-scenario page matrix was then run three times, once per
engine, against the matching schema:
| Engine | Schema | New violations vs TFS 1.4 baseline |
|---|---|---|
| TFS_10 | forgottenserver 1.4 | baseline |
| TFS_16 | forgottenserver 1.6 | 0 |
| CANARY | canary main | 0 |
emits INET6_ATON('203.0.113.45') and CANARY emits
`internal_bid` AS `bid` and the premdays-based premium update.
config.php is unchanged apart from documenting the new options; the default remains TFS_10.
Payment gateways audited and fixed
All three gateways (PayPal, PagSeguro, PayGol) were traced end to end: config keys vs code usage,schema tables, and the credit-the-points flow.
Missing PagSeguro tables (blocker)
znote_pagseguro and znote_pagseguro_notifications were used by pagseguro_ipn.php andpagseguro_retorno.php but created by nothing β not znote_schema.sql, not any Lua script.
PagSeguro could never have worked on a fresh install; every insert failed.
Both tables are now in engine/database/znote_schema.sql. The definitions are not invented β they
were already documented in the comment block at the top of pagseguro_retorno.php; they have been
added with the file's own conventions (ENGINE=InnoDB, no FK) plus an index on transaction,
which every lookup uses.
PayPal: duplicate transactions were never rejected
$txn_id_check = mysql_select_single("SELECT `txn_id` FROM `znote_paypal` WHERE `txn_id`='$txn_id'");if ($txn_id_check !== true) { // <-- always true
mysql_select_single() returns array|false and never returns true, so the guard could not
fire. Any PayPal IPN could be replayed and credited repeatedly β and PayPal legitimately retries
IPNs, so this could double-credit without an attacker. Changed to === false so the payment is
processed only when the transaction has not been seen before.
PayGol: no idempotency at all
There was no check that a message_id had already been credited, so a retried or replayed callbackcredited points again. Added a message_id lookup against znote_paygol before crediting.
(The PayGol secret-key check was fixed earlier in this PR β it compared against $paygol['secret']
while config defines secretKey, so the 403 guard never fired.)
CURLOPT_BINARYTRANSFER β deprecated in PHP 8.4
ipn.php set CURLOPT_BINARYTRANSFER, which has been a no-op since PHP 5.5 and is deprecated asof PHP 8.4, so it would emit a deprecation notice on the versions this release targets. Removed.
This one was invisible to the rest of the verification: the test environment has no curl extension,
so no curl constant is ever evaluated there. It was found by reading the gateway code directly.
Unguarded row reads on all three gateways
Every gateway did $row['column'] on the result of a mysql_select_single() that can returnfalse (missing znote_accounts row, failed lookup). On PHP 8 that is a warning and the points
maths silently treats the balance as 0. All now check is_array() first, and PayPal/PagSeguro log
the anomaly to their transaction tables instead of silently miscrediting.
Verified
- All 117 files lint clean on PHP 8.1 / 8.2 / 8.3 / 8.4 / 8.5.
- Full page matrix clean on 8.3 / 8.4 / 8.5.
- Schema guard run over the whole site against all three server schemas: TFS_10 (1.4),
TFS_16 (1.6) and CANARY β 11 findings each, zero delta between engines, and all 11 are
pre-existing or known false positives (see below).
Remaining schema findings (all pre-existing, none fixed)
| Finding | Verdict |
|---|---|
| onlinetime* on znote_players (toponline.php) | Opt-in β the ALTER TABLE ships with Lua/TFS_10/globalevent powergamers/powergamers.lua |
| player_skills (special/repairSkills.php) | TFS 0.x maintenance script, manually invoked |
| accounts.premium_points (special/convertoldshoppoints.php) | TFS 0.x conversion script, manually invoked |
| aliases p / za, table ip | False positives in the guard's regex (SQL aliases; the literal text "from IP:" in a log message) |