Журнал изменений
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
Implements a dependency-free OAuth2 Authorization Code Flow with PKCE as the new external sign-in, removes the legacy uLogin integration completely and consolidates theme templates while keeping both themes self-contained.
Core changes:
- OAuth client (core/classes/oauth.php, config/oauth.php):
- Static Oauth class: provider config, PKCE auth URL, token exchange,
hardened curl layer, JWKS cache with rotation, full RS256 id_token
validation (exp/nbf/iat with skew, iss list, aud/azp, nonce)
- Microsoft email trusted only with xms_edov or consumer tenant
- state bound to the browser via __Host-oauth-st cookie (anti link-CSRF)
- One-time flow storage in {prefix}_oauth_temp (state/pending kinds), permanent links in {prefix}_user_oauth, audit log in log_oauth.log
- Account module (modules/account/index.php):
- New ops: oauth_init, oauth (callback), oauth_finish, oauth_unlink
- first sign-in offers linking to an existing account or creating a new one; OAuth-created accounts store an unguessable '!'-prefixed marker
- unlink refuses to remove the last login method
- uLogin op network and all its guards removed; login/passmail/savepass work by password state instead of the dropped users.network column
- users_temp now stores bcrypt hashes; activate() keeps a transitional fallback for old plaintext rows
- Admin (modules/account/admin/index.php):
- Provider settings (activation, Client ID/Secret) in account preferences
- oauthlist view with forced unlink via POST buttons, link cleanup on user delete, pending-users list shows the activation code instead of the stored password
- Schema and migration (setup/sql/table.sql, table_update6_3.sql):
- New tables user_oauth and oauth_temp with engine/charset placeholders
- Idempotent migration archives legacy identities as provider 'ulogin' rows and drops the users.network column
- Themes (templates/admin, templates/lite):
- OAuth buttons, finish form and profile links tab; unified post-button
- Both login blocks render one block-login-form fragment via include, fixing the broken sidebar login (wrong field name, missing CSRF token)
- block-search-form merged into search-form; nine twin fragments synced between themes; speed dial fan opens only from its toggle
- Session write lock released before scheduler jobs (index.php) so long pseudo-cron runs no longer block the visitor session
Benefits:
- Passwordless sign-in without composer dependencies on shared hosting
- No plaintext passwords at rest anywhere in the system
- Fewer duplicated templates with identical twin fragments across themes
Technical notes:
- Redirect URI: index.php?name=account&op=oauth (HTTPS required)
- Providers ship disabled; admin help documents the full setup procedure
- Verified by 29 JWT unit tests, mock-OIDC E2E flows and phpunit (194 OK)
Port the account cabinet (index.php?name=account) to the Command Deck layout from the 2026 demos: identity rail, action tile shelf, live scroll panels and a full-width lower section, all built on existing profile and category mechanics instead of new one-off styles.
Core changes:
- Cabinet page and data (modules/account/index.php, core/user.php):
- profil() renders the new account-home partial
- identity rail: profile avatar ring, online status, rating chip and the community progress ring (points, reached group, level percent)
- panels: last incoming private messages, favorites, last activity feed and the RSS reader with the legacy preset select restored
- getUserNavItems() builds icon/tone tiles per active module with the new-PM badge and favorites counter; getUserNav() renders the compact strip for inner cabinet pages via the account-nav fragment
- getUserLevelData() extracts the ring/level computation shared with the public profile view; rssfeed() serves the feed over htmx POST
- getFavoriteList() initializes its arrays (warnings on empty lists)
- Lite templates and styles (partials/account-home.html, fragments/account-nav.html, assets/css/theme.css):
- panels reuse sl-profile-panel, sl-profile-scroll and the single sl-profile-feed-live spinner, now title-line aligned in profile and cabinet; panel headings are flex with the spinner in the right corner
- action tiles reuse the files-category icon mechanics (sl-cat-ico with sl-cat-tone-N cycling); favorites rows carry configured module icons
- sl-cab-* additions cover only the deck grid, tiles, rows, badge, progress ring (sized and shadowed like the avatar) and the RSS forms
- legacy PNG navigation removed (images/account/*.png, catflex flags)
- RSS reader (core/system.php):
- rss_read() decodes the entity-encoded config template, so stored markup renders instead of printing as text
Benefits:
- cabinet matches the profile design language with no duplicated styles
- module tiles reflow automatically when modules are disabled
- RSS reading works inline over htmx without page reloads
Technical notes:
- RSS requests must POST the url (security url_get blocks URLs in GET)
- no schema changes; legacy account nav markup and icons are gone
Post-release polish of the redesigned profile plus design demos for the account home page.
Core changes:
- Profile status and identity (modules/account/index.php, account-profile.html):
- Online/offline chip with bi-wifi / bi-wifi-off on success/danger tokens; new global _OFFLINE constant and capitalized _ONLINE in all six locales
- Avatar ring colored by group like the user block: special group color first, otherwise the highest reached points group, validated hex passed as --sl-profile-ring
- Signature block, site and hub queries reuse the existing selects, no extra queries added
- Activity feed (core/user.php, account-profile-feed*.html, theme.css):
- Entry dates render through the standard date-badge fragment at system chip sizes; custom 10px sizing removed
- Spinning bi-arrow-repeat indicator on the tabs row in primary tone; overlay positioning keeps tab panels at full feed width
- Indicator pauses together with the hovered feed scroll via :has()
- Avatar geometry (theme.css):
- box-sizing fixes for the profile avatar and the user block ring and avatar, so images no longer overlap the conic ring and sit centered
- Dead code (theme.css, modules/account/index.php):
- unused sl-user-group-special styles and the unused g.color column in the profile select removed
- Cabinet design demos (demo/):
- account-home-2026.html with six directions and account-home-final-2026.html with three finals, a module toggle simulator, auto-fit action shelf, files-category tone tiles and auto-scrolling panels; deferred work listed in docs/PROFILE-BACKLOG.md
Benefits:
- Status and group identity readable at a glance on any profile
- Feed visuals match the sitewide chip and date language
- Ready-to-transfer blueprint for the account home redesign
Replace the legacy table profile (account&op=view) with the approved Dense Split composition: identity rail, trust strip, symmetric fact panels, module contribution hub and a tabbed last-activity feed, all rendered from live data in four SQL queries.
Core changes:
- Profile view (modules/account/index.php):
- Rebuild view() around template data arrays: role-aware fact panels, trust
strip, level progress and group chips
- online status via a subquery in the main user select
- one groups select drives chips, rank fallback and level thresholds
- module hub counts, weighted ratings and favorites in one UNION ALL
- hidden fields keep the localized placeholder for panel symmetry
- profil() reuses the shared feed; the legacy last() helper is removed
- Shared profile helpers (core/user.php):
- getProfileModules() maps module tables, icons, rating column pairs and favorites keys in one place
- getProfileLastView() renders the per-module activity feed from a single
UNION ALL round-trip and respects per-module rating settings
- computed union columns are aliased because a literal 0 column would otherwise shadow numeric row index 0 under FETCH_BOTH
- Theme (templates/lite):
- account-profile.html rewritten for the split card; new feed partials account-profile-feed.html and account-profile-feed-list.html
- sl-profile-* styles on base.css tokens; chip labels truncate with an ellipsis; dead sl-table-acc-info and sl-user-group-special styles removed
- signature renders as the system blockquote and is visible to everyone
- Feed auto-scroll (plugins/system/slaed.js):
- setProfileScrolls clones visible feed entries and animates them with reduced-motion and hover-pause support, re-measuring on tab switches
- Language constants (lang/, modules/account/lang/):
- nine ACCOUNT* module constants and the global _LASTACTIVITY in all six locales
Benefits:
- Profile page cost drops from ~17 module queries to 4
- One module map feeds the hub, the feed and future consolidation
- Deferred follow-ups documented in docs/PROFILE-BACKLOG.md