Официальный сайт SLAED CMS
Журнал изменений
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
Trims the function comments added by the performance remediation work to the 180-character line limit while keeping their meaning; no code or behavior changes.
Core changes:
- Comment wording (core/system.php, core/classes/cache.php):
updateStatsCookie, updateStatsTrack, marker contract, and page-cache contract comments rewritten concisely within one line each
- Test probe (tests/Support/contract_probe.php):
- header comment shortened to the same limit
Technical notes:
- legacy over-length lines elsewhere are untouched, out of scope
Closes the correctness and availability gaps from the 2026 performance remediation plan: the public page cache can no longer be amplified by arbitrary query input or foreign hosts, and the exact visitor statistics survive IO failures without losing or double-counting entities.
Core changes:
- Bounded page-cache identity (core/system.php, core/classes/cache.php):
Cache::getQueryVars() validates the request query against a per-route key/value regex contract, dropping tracking keys centrally
- unknown, duplicate, or malformed keys render live and create no entry
- replaces the now-dead Cache::filterCacheUrl()
getCacheRouteVars() adds the canonical homeurl host gate and feeds getPageHash(), which builds the pc2-versioned identity from validated values so num=1, encodings, and foreign Host values cannot fork entries
- Dynamic-region marker contract (core/system.php):
checkDynamicMark() allowlists token scopes ajax/account/scheduler, captcha action login, and positive voting IDs
- invalid emitters poison the build, log, and fall back to live rendering
- the contract is revalidated at serve time so forged markers stay inert
- Fail-safe exact statistics (core/system.php):
v2 stats cookie carries only session metrics and country cache; no client state can suppress an exact count
ips.log/user.log sets are the source of truth with hosts/users counters derived from set size, so any single write failure self-heals
statistic.log is replaced atomically via temp file and rename under a stable statistic.lock; partial writes can never trigger a day reset
rollover verifies full day lines, rolls back short appends, aborts on any archive/rename/unlink failure before destroying state, and names archives from the data date
- checkUniqueIp()/check_user() consolidated into updateStatsTrack()
- Regression coverage (tests/):
PageCacheContractTest exercises the production functions directly and through contract_probe.php, which boots the real core per scenario with LOGS_DIR redirected to scratch so failure paths stay assertable
Benefits:
- guest requests cannot grow the cache key space or host namespaces
- exact unique hosts/users stay correct across injected IO failures
- rotation failures preserve data and retry instead of deleting sources
Technical notes:
pc2 identity version makes pre-contract cache files unreachable; normal GC removes them without a purge
- v1 stats cookies are discarded by design (disposable analytics state)
OPcache/cron deployment (Batch 4) remains a manual environment step; docs/PERFORMANCE-REMEDIATION-2026.md stays OPEN with recorded results