Официальный сайт SLAED CMS
Журнал изменений
Stages 4 and 5 of docs/COMMENTS-REDESIGN-2026.md. A comment action answers the one comment it touched instead of repainting the whole list, every mutation is a POST with its token out of the URL, and a comment may now answer another one: the reply is stored as a parent id and a sortable path, the page paginates root comments and carries each root with its branch, and a removed comment that still has a live reply stays as a tombstone.
Core changes:
- Fragment responses (core/user.php, core/system.php, index.php):
addComment() answers one comment fragment and names its own placement
- HX-Reswap and HX-Retarget decide afterbegin, afterend or the status zone
- a full slice sheds its far-end row out of band, branch included
- a pending comment answers the moderation notice, never a row in the list
- getCommentView() renders one stored comment for the list and for a response
addComment, updateCommentStatus and deleteComment are POST only, refused in one place before any handler runs
- deleteComment is a new frontend route
- status and delete name their swap in the response, so a refusal removes nothing
- updateComment() writes its refusal instead of returning it into a discarded value
- the form clears only on a stored comment, through HX-Trigger and the shared script
- Tokens and idempotency (core/user.php, core/helpers.php, plugins/system/slaed.js):
no comment action carries a token in its URL any more
- the form carries a hidden token from getPageToken()
- the moderation actions inherit one X-CSRF-TOKEN header per comment
- getTplAjaxTextarea() moved to the same shape, which takes the forum editor too
- a comment that offers no action declares no token at all
- the idempotency key is minted in the browser and renewed only after a stored comment
- Reply threads (core/classes/comment.php, setup/sql/table.sql, setup/sql/table_update6_3.sql):
- pid and an ascii-binary path of ten-digit segments, with two indexes for them
getParentPath() refuses a parent of another target, a removed one, one the writer cannot see and one already at twenty segments
- getList() counts and paginates roots and answers each with its whole branch
- getBranch() answers one branch with an explicit limit
a removed comment with a live reply stays as a tombstone, by predicate rather than by a stored flag
- existing comments become roots of their own and nothing is ever re-parented
- Pagination transport (core/user.php, templates/lite/fragments/link.html):
- the rows moved into a container of their own and the pager renders after it
- one control appends the next page and replaces itself with the answer
- its href is the ordinary page URL, so a reader without HTMX follows a plain link
getCommentPage() answers rows and the next control, and refuses a page past the last instead of answering the last one a second time
- Links that name one comment (core/classes/comment.php, core/user.php):
- getRootPage() answers the page a comment is rendered on
the notification and the other-page notice carry at=<comment id>, which setComShow() resolves, so an anchor no longer dies when newer comments arrive
- Upgrade defects found and fixed on the way (setup/sql/table_update6_3.sql):
_users dropped network in the same file that modified it, so a re-run failed and discarded thirteen other column changes with it
_admins.editor was declared BOOLEAN while table.sql defines VARCHAR(32): it failed on any installation carrying an editor name, and would have destroyed those names had it ever passed
- _users.points was declared twice and the second declaration undid the first
- table.sql aligns points to NOT NULL DEFAULT 0
- Page cache (core/classes/comment.php, index.php):
- the five writes of the class invalidate after their own writes succeeded
- a refusal, a no-op transition and a replayed key invalidate nothing
- the ajax router no longer decides invalidation by route
Benefits:
a comment action costs one fragment instead of a 51 KB list repaint, and the reader keeps scroll position and focus
- no token is baked into a URL or served to a reader who cannot act
- a link to a comment resolves for as long as the comment exists
- a discussion carries structure instead of a naming convention
- an upgrade no longer discards its own column changes without saying so
Technical notes:
_comment gains pid INT UNSIGNED and path VARCHAR(255) ascii_bin plus modul_cid_pid_time and modul_cid_path; the backfill runs before the indexes
a fresh install and the upgrade produce an identical table definition, rehearsed twice against the pre-6.3 shapes
SchemaUpdateValidationTest compares column definitions and rejects a column declared twice with two definitions
- four new language constants in all six locales; _PCOPEN removed with its last reader
CommentTransportTest (15 cases) and CommentThreadTest (10 cases) both fail against the pre-change tree
comment pages stay canonical to their target page, so pagination adds no indexable URLs
Stages 3 and 4 of docs/MAIL-2026.md. A mailing stops being a list of addresses rendered into a select and posted back, and becomes a criterion the scheduler expands into the queue in resumable slices, guarded by a suppression registry, a canary slice and a circuit breaker. The queue it writes into gains the admin view that was missing, so an installation can no longer accumulate mail silently.
Core changes:
- Campaign state and the suppression registry (setup/sql/table.sql, setup/sql/table_update6_3.sql):
{prefix}_newsletter carries the campaign state machine
- status, audit, apar, cursor, expect, total, fails, note
- mails is dropped, after setup/index.php has moved anything still pending into the queue
{prefix}_maildead added, keyed by the normalised address
- email uses a binary collation so case folding lives in the normaliser alone
- delcol joins the idempotent migration helpers beside addcol and addidx
- Delivery, verification and campaign control (core/classes/mail.php):
checkAddress() runs the ladder: syntax, recorded outcomes, cached domain resolution
- every step fails open, so no check can become a precondition for delivery
failures are classified by the phase that produced them
- only a permanent verdict at RCPT TO may touch the suppression registry
- a transport-wide refusal stops the run instead of spending an attempt per row
setCampReady(), setCampFree(), setCampAbort() and the circuit breaker
- the sample is drawn one address per recipient domain and never releases the rest
- getStats(), getList(), setQueueRetry() and deleteQueueRows() serve the queue view
- Audience producer (core/system.php):
- updateNewsletter() expands one criterion in slices under a time budget, writing its cursor
- getMailAudience() and getMailAudienceNum() resolve a criterion to its query and its count
- addAdminMail() no longer rewrites _admins.modules; the admin screen owns that write
- addMailTask() reports a transport-wide refusal as a failed run
- Admin screens (admin/modules/newsletter.php, admin/modules/config.php):
- the audience selector stores a criterion and never a list of addresses
- the queue tab filters by kind and status and offers retry and delete
- every state change is a POST carrying checkSiteToken()
- the mail tab gains the verify and dnsttl rows
- Documentation and locales (docs/, admin/lang/*.php, README.md and the root documents):
- both stages recorded in the plan with their decisions, defects and open blockers
- 21 constants added to all six admin locales
- root documents drop their "last updated" lines and state the 8.4 requirement
Benefits:
- a mailing of any size stores one row per recipient and one copy of its body
- a damaged list is measured before it is sent to rather than after the damage
- a failed message is visible, retryable and explains itself in the operator's words
- a queue behind a stopped drain now says so on the screen that lists it
Technical notes:
- config/newsletter.php holds campaign policy alone; count and active are gone
- the newsletter scheduler job is now the producer and ships active
- the fresh schema and the upgraded schema were diffed and are identical
- breaking: {prefix}_newsletter.mails is removed, so the 6.3 upgrade must run first
Both plans keep their scope and gain the contracts their reviews found missing: the backup plan pins the scheduler boundary it depends on, and the upload plan pins the storage contracts its callers already rely on.
Core changes:
- Backup plan (docs/BACKUP-2026.md):
the scheduler boundary is specified rather than assumed
- every state-changing action is POST; GET only displays state
- browser actions validate the SLAED CSRF token, cron authenticates by bearer
token in the body and never through the URL
- an OS flock keyed by the canonical job name owns a run; JSON status and
heartbeat are diagnostic only and unlock may not break a live lock
dbbackup.settings defaults are synchronized across both config sources, and the 6.3 upgrade fills only missing keys
the nine migration helper procedures are a release prerequisite; Backup fails closed while they exist and never deletes schema objects
- schema fingerprinting excludes AUTO_INCREMENT counters and table statistics
- scheduler row actions render through the real dial fragment
- the verification matrix names upgrade, route, concurrency and restore tests
- Upload plan (docs/UPLOAD-2026.md):
deleteStoredFile() joins the public surface for the orphan case
- a database write that fails after publication removes that exact result
- containment and the destination lock are re-checked on the delete path
paths are root-relative on both ends, and the stored name keeps the owner suffix the editor ownership filter reads
- go=4 stays as the editor route with two explicit cases and rejects the rest
DNS resolution, cURL execution and the clock sit behind replaceable internal methods so the security cases are testable without a network
Benefits:
- both plans can now be handed to an implementer without a second review round
- the backup plan no longer depends on scheduler behaviour nobody wrote down
- the upload plan states the contracts a caller would otherwise rediscover
Technical notes:
- documentation only; no code, schema or configuration changes
- neither plan is started, and both keep their no-compatibility-wrapper rule
Stage 3 of docs/COMMENTS-REDESIGN-2026.md. The admin notification of a new comment is a queue row written in the transaction that stores the comment, so a comment that never commits leaves no mail behind and a job is written once per stored comment. Nothing is delivered while the visitor waits.
Core changes:
- Comment submit handler (core/user.php):
addComment() owns the transaction the comment and its queue row share
- Comment::addComment() joins it instead of committing its own
- addAdminMail() writes the queue rows inside it
- the commit closes both, and any refusal rolls both away
- the notification is written only for a comment this request stored
- Comment subsystem (core/classes/comment.php):
addComment() answers a fourth key, new, beside id, name and error
- true only on the return that follows a successful insert
- getKeyResult() answers false, because a replay stores nothing
- every refusal answers new as false as well
- Stage guard (tests/Unit/CommentNotifyTest.php, tests/Support/contract_probe.php):
the commentnotify probe drives the two writes in the handler's order inside a transaction it always rolls back
- one add stores one comment and queues one row per subscribed administrator
- the rollback takes the comment and its job away together
- a replay, a refused add and a rejected address each write nothing
the handler cannot run under CLI because getVar() reads scalars through filter_input(), so its wiring is asserted against its source
- six of the ten cases fail against the pre-batch tree
Benefits:
- a stored comment and its notification can no longer disagree with each other
- a replayed submit no longer notifies the administrators a second time
the comment write and its notification cost 5.8 ms together, against 77 ms for one page render; the 26.6 s synchronous mail() is gone from the request path
Technical notes:
no schema change; the queue row is written through Mail::addQueue(), which is documented as being called inside the caller's transaction
a failed queue write is deliberately not checked: a statement that fails does not abort the transaction, so the comment still commits
breaking change for callers of Comment::addComment(): the returned array carries a new key, and a replay must no longer be treated as a stored comment
Stage 2 of docs/COMMENTS-REDESIGN-2026.md. The comment body becomes the source the author wrote and the parser escapes it on read, every write is transactional and idempotent, and the table gains the columns and indexes that make a soft delete, an idempotency key and a keyed flood fingerprint possible.
Core changes:
- Comment subsystem (core/classes/comment.php):
checkRules() replaces checkAddRules()/checkEditRules() as one ordered rule set
- the length rule measures the longest word, not the last one
- it counts characters instead of bytes
- the add-only rules (guest name, flood window, captcha) stay bound to the add
add, edit, status and delete are transactional with checked results
- status and delete are conditional updates, so a parallel request cannot count twice
- the row is read FOR UPDATE inside the same transaction
- an operation joins a transaction that is already open instead of refusing
- deleteComment() marks the row instead of erasing it, and every read filters it
- addComment() stores reqkey, iphash and format and answers a replay from the failed insert
- filterCommentBody() replaces filterHtml() in the write path and stores source
- getBodyFormat() refuses html as a comment format
- CommentMode replaces the bare acomm comparisons, getTargetMode() answers the enum
- listings sort on time, id
- Parser (core/classes/parser.php):
filterContent() and filterDoc() take the source format as a fifth argument
- plain recognises no Markdown construct and turns line endings into breaks
- anything else is Markdown, which is what every existing caller gets
- the inline BB pairs stash their tags, so safe = true keeps them readable
- Schema (setup/sql/table.sql, setup/sql/table_update6_3.sql):
- five columns added: format, edited, deleted, reqkey, iphash
- five indexes added, cid and modul_status dropped, KEY time kept
- new addcol procedure, reqkey backfilled before its unique index is created
- a fresh install and the upgrade produce an identical table definition
- Storage migration (tools/comment-migrate.php):
- classify writes the format of every row in its own reviewable pass
- convert rewrites the bodies per class, in batches, resumable through a ledger
- iphash backfills the flood fingerprint from the stored address
- report and sample answer read-only, and --db runs the whole thing on a copy
- Render sites (core/user.php, core/system.php, admin/modules/comments.php):
- all four comment renders moved to safe = true and pass the row format
- the moderation save reads the raw field, because the body is source now
- Tests (tests/Unit/CommentStateTest.php, tests/Support/contract_probe.php):
- 15 cases over a probe that signs in as an administrator before the core boots
repeated status transitions, idempotent delete, replayed key, stable sort, round-trip of both classes and the write-path normalisation are all measured
- the stage 0 guard and the parser fixtures follow the two shapes this stage changed
Benefits:
- stored XSS through the comment path is closed by the rendering model itself
a moderation click, a double submit and a repeated delete can no longer move a target counter or an author's points twice
- the flood check runs on an indexed fingerprint instead of a plain address
- the list, its count and its pager can no longer disagree about a deleted row
Technical notes:
- the body format is stored per row: plain or markdown, never html
the upgrade is idempotent, but tools/comment-migrate.php is not optional - until classify and convert have run, comments render with their old escaping
- 121 of 7353 rendered comments change on purpose and are recorded in the plan
docs/BACKUP-2026.md and docs/UPLOAD-2026.md carry an unrelated rewrite that was already in the working tree
Both proposed plans are corrected where their own self-review found a contract that would still have been unsafe or non-deterministic when implemented, so neither can be built from a design that has a known gap in it.
Core changes:
- Scheduler access and locking (docs/BACKUP-2026.md):
the access matrix is written out per entry: an authenticated administrator is never a token bypass, pseudo-cron accepts only the session-bound site token, cron only the configured static one, and manual or unknown triggers are rejected on the direct endpoint
job exclusion moves from scheduler JSON to a process-held non-blocking flock kept for the whole run, so lock_timeout stays a budget and a diagnostic rather than permission for a second process to start
the shared admin dial gains a form mode: edit stays an anchor, run, unlock and delete become submit buttons inside one POST form the template owns
- Deterministic export and restore (docs/BACKUP-2026.md):
the export session fixes time zone and SQL mode beside the isolation level and restores every value it changed, and failing to establish or restore it is a failed run
- the dump prologue sets the same interpretation on the restoring side
- the verification list, the risk table and the completion criteria follow
- Remote transfer (docs/UPLOAD-2026.md):
CNAME traversal is bounded and fails closed, every A and AAAA answer of the terminal name is validated, and a host without a validated public answer is rejected
environment and configured proxies are disabled per hop, because a proxy moves DNS and connection enforcement outside the application
- the work order, the security criteria and the risk table follow
Benefits:
the two remaining procedural subsystems have plans whose security boundaries are settled before any code is written
the corrections are recorded in each plan's progress table, so a later session can see that the earlier revision was superseded rather than lost
Technical notes:
- no PHP source changed and neither migration has started
both plans keep replacing their contracts outright, with no compatibility wrapper and no behavior-preserving intermediate implementation
The order table of the shared execution cookbook records that the mail queue and drain stage is finished and committed, so the next chat opens at the row after it instead of re-reading a stage that is already in the tree.
Core changes:
- Order of work (docs/EXECUTION-2026.md):
- row 4, mail stage 2, struck through and marked done
the next open row is comments stage 2, which still needs its one comment-baseline capture before its first chat
Benefits:
- the cookbook keeps telling a fresh session where to start
Technical notes:
- no code changes and no changes to the prompts or the templates
The mail plan gains its stage 2 entry and is corrected wherever the implementation measured something the plan had assumed, so a session reading only the plan sees the delivered design rather than the designed one.
Core changes:
- Progress and decisions (docs/MAIL-2026.md):
- stage 2 recorded: what was built, what was verified and against what
the claim is described as it is implemented and measured: the predicate is the leading columns of the claim index, and exclusivity comes from the claim moving a row behind the lock window
- the measurement that decided it is written down, 202 ms against 1.9 ms
the upgrade statement lives in table_update6_3.sql, with the reason: 6.3 is the release this ships in, and a new file would give a fresh install the table and an upgraded one nothing
the drain job is priority 2, as the plan's own paragraph argues; its code block said 1 and is corrected
an empty drain run reports success and never idle, because the scheduler counts every other status as a failure and this job runs every five minutes
- retention is keyed on the creation stamp, the only one the table has
- the test send reports what the run did, not what the queue accepted
- Blockers (docs/MAIL-2026.md):
concurrency closed: two processes racing one queue of 400 rows claimed 200 each with no overlap
added: delivery is still proven only against a loopback sink, the transactional retention delete cannot use an index prefix, and the queue rows on the mail tab were verified as controls rather than as behaviour
- Facts re-measured:
- the admin config module line references, shifted by the six queue rows
Benefits:
the two places where the plan contradicted itself or the engine are settled in the plan rather than only in the code
- the next stage starts from measured numbers
Technical notes:
- no code changes
the execution cookbook is left untouched; marking the order table is the reader's step after the commit
Stage 2 of docs/MAIL-2026.md. addQueue() stores a row and answers accepted into the queue; delivery moves to a scheduler job that claims, sends and records under a lock, so no request waits on a transport and no failure stays invisible. No call site changes: all 26 moved to $mailer->addQueue() in stage 1.
Core changes:
- The queue table (setup/sql/table.sql, setup/sql/table_update6_3.sql):
{prefix}_mail holds kind, sender, email, title, body, ref, prio, time, ntime, tries, status, camp, hold, locked, lockid, phase, code and error
the claim index leads with hold, status, prio, ntime, id; kind_status_time and kind_ref_status serve retention and the campaign queries stage 3 adds
the upgrade statement creates the table with its primary key alone and adds every secondary index through addidx, so a half-applied schema is repaired instead of refused
- Queue and drain (core/classes/mail.php):
addQueue() stores instead of delivering; every value is bounded against the column that holds it, and an oversized subject is refused where the caller can still be told rather than by a failing write
getBatch() claims one batch in a single conditional UPDATE and moves the rows behind the lock window, which is what makes the claim exclusive
- the predicate is exactly what the claim index leads with: with a marker
column in it the optimizer left that index and filesorted 99 267 rows
setResult() records the outcome, grows the backoff per attempt and fails the row at the attempt cap; a reference to a body that is gone is not retried
updateQueue() is time-boxed rather than count-boxed, honours mail.rate across runs through the job state, and keeps one transport connection for the run
- deleteQueue() prunes accepted rows per kind and never touches a failed one
- sending is private: the queue is the only way out
- Scheduler job (config/scheduler.php, core/system.php, setup/index.php):
- maildrain ships active, runs every five minutes and stays manually triggerable
addMailTask() maps a run to the job status: an empty queue is a success, a run that refused everything it tried is a failure
- the upgrade inserts the job into an existing config/scheduler.php
- Queue settings and the test send (admin/modules/config.php, admin/lang/*.php):
batch, rate, tries, backoff, keep and keepbulk get a row on the mail tab and are validated on save
the test send queues and then drains inside the request, so the button still reports the transport's own words; a run that sent nothing says so instead of claiming a delivery nobody observed
- seven constants added to all six locales
- Tests (tests/Unit/MailQueueTest.php, tests/Unit/MailDrainTest.php, tests/Support/):
- 25 tests over the statements the queue issues and the bounds it holds
15 tests driving the live database and a loopback SMTP sink through tests/Support/mail_probe.php and tests/Support/mail_relay.php
- the stage 1 transport tests now enter delivery the way the drain does
Benefits:
a request that sends mail returns at once: the security notice measured here queues in 0.11 s where the same path used to wait on mail()
a refused delivery is retried behind a backoff and recorded instead of being discarded
- one SMTP handshake per drain run rather than one per message
the claim reads its 25 rows in 1.9 ms with 100 000 pending, against 202 ms before the predicate was written against the index
Technical notes:
new table {prefix}_mail; a fresh install and an upgrade produce the same definition, the upgrade is idempotent and repairs a partially applied one
- two processes racing one queue of 400 rows claimed 200 each with no overlap
breaking change: mail becomes asynchronous on every installation that takes this release, and addQueue() answers accepted into the queue, never delivered
- no compatibility layer and no runtime schema detection
- config/mail.php is unchanged: stage 1 already shipped every queue key
Two more procedural core subsystems get a concrete migration plan in the shape the 2026 plans already use, and the analysis document that only listed candidates is retired now that its two remaining entries have plans of their own.
Core changes:
- Database backup (docs/BACKUP-2026.md):
addBackupTask() is replaced by one final Backup class owning database export and the creation of one verified, restorable artifact
- scheduler access, locking, state and presentation stay in the scheduler
named Backup rather than DatabaseBackup, with addDatabaseBackup() as its first public operation so the short class name does not imply filesystem coverage
one atomic replacement: atomic output, consistent reading, checked writes, exact artifact identity, cleanup and restore verification are part of the first implementation rather than a later hardening pass
- Uploads (docs/UPLOAD-2026.md):
- upload(), check_file() and check_size() are replaced by one Upload class
the separate editor upload implementation joins the same validation and storage boundary
owner context, quota locking, DNS-pinned remote transfer, stale-partial recovery and the supported-flow criteria are settled without fallbacks
- Retired analysis (docs/REFACTOR_CLASS_CANDIDATES.md):
removed; the candidates it ranked are covered by the mail, comment, backup and upload plans
Benefits:
both subsystems now have a plan that states a final contract instead of a direction, so neither can be half-migrated
the docs directory holds plans that are executed rather than a list that is read
Technical notes:
- no PHP source changed
- both plans are proposed, not started
both replace their contracts outright: no compatibility wrapper and no behavior-preserving intermediate implementation