Журнал изменений
The comment write path decided premoderation from a request field and stored the target module straight from the request, so a crafted POST could publish past premoderation, attach a comment to another module and move another target's counter. Stage 0 of docs/COMMENTS-REDESIGN-2026.md closes that boundary in the current procedural code, ahead of the refactor.
Core changes:
- Target resolver (core/system.php):
Add getCommentMode($mod, $id): the module name is only a key into a fixed eight-entry map, never an interpolated table name
- the target row is loaded by id and must be visible under the same predicate
the module's own view() uses: time <= NOW(), status != '0' and catmids()
- voting keeps its own rule: modul = '' plus the enddate/status pair
- returns the row's acomm; 0 means not writable - unknown module, missing row,
invisible row or comments disabled
updateComment() and updateCommentStatus() read modul, cid and uid from the comment row by its id
- the permission check and numcom() now use the stored module
- updateCommentStatus() reads the row once instead of re-selecting it after
the status write
- Write handler (core/user.php):
addComment() resolves the mode through getCommentMode() and no longer reads cid from the request
- a target with comments disabled refuses the write for everyone, moderators
included
- setComShow() drops cid from the submit URL and names its parameter acomm
- Regression cover (tests/Unit/CommentTrustBoundaryTest.php, tests/Support/contract_probe.php):
Add the comment probe scenario: getCommentMode() is exercised against live rows in a booted core, with hostile cid and mod values in the request
Add six cases over the resolver and the three request handlers; all six fail against the unpatched files
Benefits:
Premoderation, the stored module and the target counter can no longer be chosen by the client
The module-to-table mapping exists in exactly one place, ready to move into the Comment class in stage 1
- A draft, an expired item and an unreadable category stop accepting comments
Technical notes:
No table or column changes; rendered markup is unchanged and the comment markup baseline verifies byte for byte
The request still carries mod as a lookup key; the moderator action links keep their now-ignored &mod= until the transport stage
- Rendering stays at safe = false until stage 2, together with the format column
Closes a stored-XSS vector reachable by any comment author, and lands the approved 2026 plans for the outgoing mail subsystem and the comment engine together with the tooling and the execution protocol they depend on.
Core changes:
- Parser URL validation (core/classes/parser.php):
refuse data:, javascript: and vbscript: in every mode, not only in safe mode
- comments render at safe=false, so the previous allowlist never applied to them
- [url=javascript:...] produced a working href for anonymous authors
- the scheme is compared against a copy stripped of whitespace and control
characters, so java	script: cannot survive entity decoding
- keep data: images working, which bypass this validator by design
- Parser regression tests (tests/Unit/ParserFixturesTest.php):
eight byte-exact cases covering unsafe javascript:, vbscript:, mixed case, leading space, embedded tab and entity-encoded tab
- verified to fail against the unpatched parser before the fix was applied
- Mail plan (docs/MAIL-2026.md):
- transport stage first: PHP mail, Sendmail and SMTP in one class, no library
queue and drain second, with atomic claim, backoff, retention and a campaign state machine covering preparing, canary, held, running and aborted
- SMTP outcome taxonomy by phase, suppression only on a permanent RCPT TO verdict
- audience stored as a criterion and expanded by a resumable producer job
- Comment plan (docs/COMMENTS-REDESIGN-2026.md):
stage 0 closes the request-side trust boundary: acomm, modul and cid resolved server side through a fixed module map
- stage 1 centralises every _comment statement into one class with byte parity
- stage 2 moves rendering to safe=true behind an ordered body migration
- Markup baseline tool (tools/comment-baseline.php):
captures and verifies the rendered comment list per module so the stage 1 parity requirement is checkable rather than asserted
- covers all eight comment-bearing modules
- Execution protocol (docs/EXECUTION-2026.md):
- one batch per chat, ready-to-paste prompts, per-batch verification loop
Platform requirements (composer.json, setup/index.php, setup/lang/*, README.md, UPGRADING.md, package.json):
- raise the PHP requirement to 8.4 and enforce it in the installer
- declare ext-mbstring, ext-pdo and ext-json and check them on install
- add _EXTSETUP to all six setup locales
Supporting documentation (docs/PERFORMANCE.md, docs/PRINCIPLES.md, docs/TESTS.md):
record the measured mail, comment and user-base facts where they outlive the plans
- add the self-contained principle and the planned test coverage
Benefits:
removes an exploitable stored-XSS path across comments, forum and every other content surface
- makes the comment refactor verifiable instead of trusted
- gives both plans a single execution contract that survives between sessions
Technical notes:
- BREAKING: the minimum PHP version is now 8.4; the installer refuses 8.1 to 8.3
- BREAKING: ext-mbstring, ext-pdo and ext-json are now required explicitly
- BREAKING: config/modules.php ships with every module active
- storage/baseline is ignored by git: baselines are data- and host-specific
- no runtime package was added; composer.json still declares only the platform
Posting a comment from the frontend was impossible: the editor only copied its content into the textarea on a native form submit, which htmx never triggers, so an empty text reached the server. Investigating it produced measurements that justify two follow-up plans.
Core changes:
- Editor value synchronisation (plugins/editors/toastui/driver.php):
Bind the copy to editor events instead of the form submit event
- change and blur keep the textarea current at all times
- the submit listener stays for the plain, non-htmx path
Frontend forms post the written text again; the admin panel is unaffected because it always submitted natively
- Comment subsystem plan (docs/COMMENTS-REDESIGN-2026.md):
Record the measured state: a 252-line render function, a 51 KB response for a single add, filesort on the list query, no index behind the flood check, duplicated validation and an unreachable admin branch
Describe the target: one Comments class owning SQL, validation, permissions and state changes, additive columns only, threads, soft delete, idempotent writes and per-fragment responses
Split the work into five independently shippable stages with explicit verification, including migration and HTTP route checks
- Mail queue plan (docs/MAIL-QUEUE-2026.md):
Record the measured state: 26.6 s spent inside one comment request, a single send point that discards the result of mail(), a private newsletter queue built on a comma separated column, and a throughput of four messages per hour
Describe the target: one queue table, one MailQueue class behind the existing addMail() signature, an atomic claim, backoff and retry, and a scheduler drain
- Keep all 26 existing call sites untouched
Benefits:
- Comments can be posted again from every frontend module
- The two reworks start from measured facts instead of assumptions
- The mail queue removes the blocking send for every feature, not only comments
Technical notes:
- The editor change is client side only; no PHP behaviour changes
- Both documents are plans; no schema or runtime change is included here
- The mail queue is a prerequisite for stage 3 of the comment plan
Rewriting the input filter tests against the shipped functions instead of local replicas exposed a fatal in filterFields() and two silent behaviour drifts. The captcha helper also poisoned every cacheable build unconditionally, even when captcha was switched off and returned an empty string.
Core changes:
- Scalar input reaching filterFields() (core/security.php):
Branch on is_array() instead of the truthiness helper isArray()
- A non-empty string passed the old check and hit implode(), raising a
TypeError that killed the request
- getVar(..., 'field') feeds exactly that, so account, help, order, forum,
news and pages could fatal on a scalar field value
- Filter a scalar through the text filter instead of returning an empty string
- Captcha as a dynamic region (core/system.php, core/user.php, modules/*):
- Accept register, comment and contact next to login in checkDynamicMark()
Read the captcha through getPageCaptcha() on 17 frontend call sites
- getCaptcha() marks the build as uncacheable before it even asks whether
captcha is active, so a form dropped the page cache in every configuration
- The admin login keeps the direct call: the admin area is never cached
- Comment length guard (core/user.php):
Compare the longest word instead of the last one
- The loop overwrote its own result, so only the final word was measured
- Count characters with mb_strlen() so cyrillic is not charged twice
Filter tests without replicas (tests/Unit/InputFilterTest.php, tests/Support/contract_probe.php):
- Drive the real functions through a new filters probe scenario
Correct three assertions that described the replicas, not the code
- filterVar() returns an empty array, not an empty string, for a bad list
- filterHtml() loses a lone backslash to stripslashes before encoding
- filterHtml() returns an empty string, never null
- Extend the marker contract test with the widened captcha whitelist
Naming and memory (core/security.php, core/classes/parser.php, admin/index.php):
Rename the global filterUrl() to filterWebUrl()
- It normalises a submitted address and did not relate to the parser link
policy Parser::filterUrl(), which refuses dangerous schemes
- Build the getVar() filter table once per request instead of per call
- Hash the image memo key so an inline data URI is not held twice
Benefits:
- A scalar field value no longer fatals the request
- Pages carrying a form stay cacheable
- The filter tests fail when the filters change, which they did not before
Technical notes:
- No schema, route or storage changes
- checkDynamicMark() still rejects adminlogin, empty and unknown parameters
- InputFilterTest shrank from 31 replica-based cases to 8 contract cases
The variable naming rule demanded at least two characters, which the code base contradicted in about eighty places, including the filter table in getVar(). A rule that is never followed stops guiding anything, so the exception is written down instead.
Core changes:
- Naming rule (CONTRIBUTING.md):
Allow a single letter where the name carries no domain meaning
- Loop counters and parameters of single-expression closures
- Any variable holding a domain value keeps the 2-8 character rule
- Test inventory (docs/TESTS.md):
- List the unit tests that were added since the file was last updated
- Note that the contract tests drive production code through the CLI probe
- Completed plans (docs/FRONTEND-DIAL.md, docs/PROFILE-BACKLOG.md):
- Remove the plan documents whose work is finished
Benefits:
- The written rules match the code that reviewers actually read
- Newcomers find the current test layout instead of an outdated list
Technical notes:
- Documentation only, no runtime change
Five admin handlers addressed a name[] form field with a scalar key or passed an array default into a scalar filter. The result ranged from a log warning to settings that were silently discarded on every save.
Core changes:
- Language editor (admin/modules/lang.php):
Read constants and translations by index through nested keys
- Form fields carry an explicit index and a hidden row count
- The saved file no longer depends on browser field ordering
Stop overwriting the posted translations with the file contents
- The loop variable holding the file body was shadowing the input
Add getLangConstants() and use it in both the editor and the save handler
- It understands single and double quoted values, so an apostrophe in a
translation survives instead of truncating the parsed value
- Escaping on write covers the backslash as well, so a trailing backslash
can no longer break the generated file
- Remove dead merge and unset code left from an earlier revision
- RSS feed list (modules/rss/admin/index.php):
Read the 50 rows as untyped arrays so their indexes stay aligned
- The previous scalar key produced false and wiped every feed to zeros
- Strip the pipe separator and line breaks from the stored values
Drop the required attribute from the hidden feed rows
- With 49 empty required rows the browser refused to submit the form at all
- Comments, sitemap and account (admin/modules/, modules//admin):
Address the checkbox and select arrays as id[], mod[] and warn[]
- Single-row approve and delete no longer log an array conversion
- The sitemap module selection is stored instead of falling back to zero
- User warnings survive a redisplay of the account form after an error
Benefits:
- Language and feed editing actually persist what the admin entered
- Admin actions stop writing warnings into the PHP log
- Existing values keep their meaning: nothing is rewritten on read
Technical notes:
- No schema or config format changes
- The language editor normalises quoting on first save; values stay identical
- Verified live in the admin UI with backups restored afterwards
Form fields like lng[ru][] could not be read at all: the key syntax stopped at one level, and a mismatched key silently produced false from filter_input(), which then reached the scalar filters. Several admin forms were broken by exactly that, so the helper is extended instead of worked around per module.
Core changes:
- Key syntax (core/security.php):
Parse a key as a path so leading segments walk nested form fields
- lng[ru][] returns the whole branch, lng[ru][_A] one element
- deep[a][b][c] and row[2][id] work the same way
- The existing forms key, key[] and key[n] behave exactly as before
- Walk the path once before the branches instead of per branch
- Missing and mismatched values (core/security.php):
Treat an array reached through a scalar key as missing
- A wrong form name now yields the default instead of false
Return an array default before any scalar filter runs
- filterNum() no longer receives an array and logs a conversion warning
- Contract tests (tests/Unit/InputVarContractTest.php):
Exercise the real helper through the CLI probe, not a replica
- Typed array keys still drop empty values and reindex
- Untyped array keys return the payload untouched for index alignment
- Nested paths are covered for post, get and req, including a missing branch
Benefits:
- Forms with nested fields are readable without bypassing input filtering
- A form and handler name mismatch fails visibly instead of silently
- The helper keeps a single source of truth for input access
Technical notes:
- Purely additive for existing callers: the typed array branch is unchanged
The scalar branch reads filter_input(), which has no payload in CLI, so it is verified through real HTTP rather than the probe
Posting a comment failed for every visitor: the htmx button carried no CSRF token, so the ajax dispatcher answered with a token mismatch, and the handler read its identifiers from POST while the form sends them in the query string.
Core changes:
- Comment form (core/user.php):
Append getPageToken() to the addComment request URL
- getPageToken() is used instead of getSiteToken() so a cacheable build
receives a signed dynamic-region marker rather than a frozen token
- Comment handler (core/user.php):
Read id, cid and mod through getVar('req', ...) like updatePost() does
- The htmx button sends them as query parameters, not as form fields
Guard the flood check against a first-time visitor
- strtotime() no longer receives null when the IP has no previous comment
Benefits:
- Guests and members can post comments again
- The deprecation notice disappears from the PHP log on every first comment
Technical notes:
- No schema or route changes; only input reading and the request URL differ
- Verified through a real guest submission followed by admin moderation
The parser passed any data: URI through untouched, so a guest without upload rights had an unlimited byte channel into the database and a clickable data:text/html href in every comment. Enforcement now happens at render time, which also covers rows that are already stored.
Core changes:
- Image sources (core/classes/parser.php):
Accept a data: source only as a whitelisted base64 raster image
- Allowed types are png, jpeg, jpg, gif and webp, matched case-insensitively
- The decoded payload must not exceed Parser::EMBEDMAX (65536 bytes)
- Anything else returns null and renders the existing image placeholder
Cap the encoded length before the regex or base64_decode() runs
- A multi-megabyte payload is refused without allocating a decoded buffer
Force the derived file name to 'image' for data: sources
- A rejected payload no longer leaks into the alt and title attributes
Cover all three ingestion paths in both safe and unsafe mode
- Markdown images, BB [img] and persisted raw <img src> repairs
- Link contexts (core/classes/parser.php):
Refuse data: in filterUrl() regardless of the safe flag
- Untrusted guest content is rendered unsafe, where the policy was skipped
- Trusted content keeps ftp:, tel: and other hrefs as before
Hand data: image sources straight to the whitelist
- Safe mode previously rewrote every legitimate embed to src="#"
Shared limit and meta images (plugins/editors/toastui/driver.php, core/system.php):
Publish Parser::EMBEDMAX to the editor as its client-side embedmax
- The client limit is UX only and can no longer drift from the server
- Drop data: sources in getImgText() so og:image points at a real resource
Benefits:
- Upload permissions can no longer be bypassed through an inline embed
- Oversized rows, heavy pages and bloated backups are prevented at the source
- A data: URI can never appear in an href, only as escaped text
Technical notes:
- Existing rows are covered because the rule runs at render time
- Legitimate embeds above 64 KiB stop rendering and show the placeholder
ParserFixturesTest covers both modes, hostile MIME tricks, whitespace variants, the exact size limit and a multi-megabyte payload
Partial writes were reported as success, so a truncated cache body or a torn counter record could survive as valid data. Public responses could also carry a Set-Cookie header into a shared proxy, letting one visitor's state reach the next one.
Core changes:
- Atomic cache body write (core/classes/cache.php):
Treat a short write as a failure instead of trusting a non-false return
- The temp file is removed without the error-suppression operator
- Only a complete body ever reaches the target path through rename()
Drop every pending Set-Cookie when emitting Cache-Control: public
- A shared proxy or CDN can no longer store a visitor-bound cookie
- Sessions and CSRF are unaffected: token and captcha pages are no-store
- Append safety for counters and logs (core/system.php):
Rework addFile() append mode around fopen/flock/fwrite
- The write position is recorded before writing
- An incomplete append is rolled back with ftruncate()
- A short write is reported as a failure to the caller
- Contract test coverage (tests/Support/contract_probe.php, tests/Unit):
Extend the CLI probe with a scratch COUNTER_DIR and new scenarios
- stathit drives the real updateStatsTrack() per process
- appendfail proves a blocked append is reported, not silently lost
- geoip measures the streaming reader against the real corpus
- Add StatsContractTest and GeoipReaderTest, extend PageCacheContractTest
- Add the getvar scenario used by the input contract tests that follow
Benefits:
- A truncated write can no longer be served as a valid cached page
- Statistics stay exact under parallel hits instead of losing records
- Public caching becomes safe for shared proxies
Technical notes:
- No storage format changes; counters and cache files keep their layout
- Backward compatible: only failure paths and response headers changed
Documentation updated in docs/PERFORMANCE.md; the remediation plan is done and its document removed