Официальный сайт SLAED CMS
Журнал изменений
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
Implements the full 2026-07 performance plan: exact locked statistics, deferred post-response tracking, derived config, streaming GeoIP, shared category map, parser cache, cache GC, and a guest page cache that keeps every visitor-bound token isolated through signed dynamic-region markers.
Core changes:
- Statistics and tracking (core/system.php):
Rewrite updateStatsTrack around one c+ handle with a single LOCK_EX
- read, day/month rotation, truncate and write share one lock
- first hit of a new day now records its own IP (hosts=1) and user
- three duplicated counter-field blocks collapsed into one
Replace sessions.log with a signed base64url stats cookie
- fields v1|sid|fst|lst|hits|country|cts|uniq-day|ip-hash, HMAC via
getSecret('stats'), verified with hash_equals, tamper resets session
- country carries a 24h TTL and is bound to the IP hash
- updateSessionState removed, sessions.log and its global lock retired
Split tracking into pre-output cookies and post-response writes
- addDeferredTask/setDeferredTasks queue with shutdown backstop
- session_write_close before deferred writes, drains on all exits
- news article counter update moved into the deferred queue
Literal stripos matching for bots, fbots, and auto_links patterns
- removes regex injection from config and DB values, caches results
- Conditional lastvis update (60s window) and _session upsert
- Session schema (setup/sql/table.sql, table_update6_3.sql):
- UNIQUE KEY on _session.uname for new installs
mksessuniq migration: dedup by max(time, id), drop old index, add unique, idempotent on re-run; code uses INSERT ... ON DUPLICATE KEY
- Derived config cache v2 (core/system.php):
getConfig stores derived data in config/local.php (version 2)
- per-theme asset manifests with stat fingerprints, parsed SEO
graph/schema templates, logo dimensions per theme
doCss/doScript drop per-request glob/stat sweeps, bundle hash only computed when bundling is enabled
- Streaming GeoIP (core/classes/geoip.php):
MMDB reader works through fseek/fread ranges instead of loading the whole database file; metadata resolved from the last 128 KiB
- byte-exact with the old reader, peak memory 25 MB -> 2 MB
- fread(handle, 0) guarded (old substr was silently empty)
Category map and parser cache (core/system.php, core/helpers.php, modules/news/index.php):
getCategoryMap consolidates two duplicated category queries behind an epoch-keyed data cache with raw titles
news list caches filterContent output keyed by content hash, parser config, theme, locale, and parser version; [block]/[hide]/[usephp]/ [attach] and local <img> content bypasses the cache
Guest page cache with dynamic regions (core/system.php, core/classes/cache.php, core/security.php, blocks/*):
Signed markers [[sldyn:type:par:hmac]] via getSecret('dynreg') keep user content from forging substitutable markers
Regions: CSRF tokens (getPageToken), captcha (getPageCaptcha), and the whole voting widget; cache files contain zero live tokens
Poison guard: a live getSiteToken/getCaptcha call during a cacheable build prevents storing the page entirely
- Default-deny route allowlist inside checkPageCache (news list only)
Fail-closed sidecar with body hash and dyn flag; dynamic pages are no-store and never answer 304; substitution runs on hit and miss
- cache = 1 enabled in config/global.php
- Cache GC (core/classes/cache.php, core/system.php):
deleteStaleTree recursively sweeps storage/cache/templates and the data cache joins the cachegc scheduler job
Benefits:
- Guest cache hit generation time 0.307s -> ~0.055s (5.3x)
- Uncached pages roughly halved (search 0.17s -> 0.09s)
- Day counters survive concurrent requests and day rollover
- CSRF/captcha isolation between visitors verified with two cookie jars
Technical notes:
- config/local.php cache version bumped to 2, rebuilt automatically
theme asset changes now require a config rebuild (admin save or deleting config/local.php)
_session migration applied to the dev DB; verify on MySQL before production rollout (developed against MariaDB 11.7)
- ip/agent freshness for logged-in users delayed up to 60 seconds
docs/PERFORMANCE.md updated; docs/PERFORMANCE-REMEDIATION-2026.md is the completed plan, ready for deletion after review
Strengthen OAuth account creation, linking, unlinking, and login finalization with fail-closed transactions, atomic temporary-state consumption, safer provider validation, and consolidated session handling. Add focused regression coverage for JWT validation, transaction failures, redirect safety, and unlink invariants.
Core changes:
- OAuth security and lifecycle (oauth.php, modules/account/index.php):
Harden redirect, provider response, JWKS, claim, and one-time state handling
- Bound provider response bodies and validate JSON content types
- Consume callback state atomically and preserve retryable pending records
Make account creation and unlink operations transaction-safe
- Roll back failed user/link creation as one unit
- Serialize unlink operations and protect the final login method
- Consolidate normal and OAuth login session finalization
- Database transaction support (pdo.php):
- Add begin, commit, and rollback primitives for shared PDO connections
- Normalize transaction exceptions to the existing boolean error contract
- Admin output and regression tests (modules/account/admin/index.php, tests/Unit):
- Render provider identity data through escaped text template fields
- Cover redirects, claims, JWT signatures, transaction failures, and unlink outcomes
Benefits:
- Prevents orphan accounts, replayed callbacks, unsafe redirects, and last-method unlink races
- Reduces duplicate login state handling and unnecessary unlink queries
- Improves maintainability with isolated OAuth and database regression tests
Technical notes:
- Uses the existing OAuth tables and configuration without schema changes
- Preserves current routes and backward-compatible account behavior