Официальный сайт SLAED CMS
Журнал изменений
The extension field of the dashboard answered one and the same string whether it had found every module of the directory loaded or had never been able to read that directory at all, so an installation whose extension_dir lies outside open_basedir was told that nothing was missing. The same commit writes down the plan for the journals and for the data around them.
Core changes:
- Three states where there were two (admin/modules/monitor.php):
Whether the module directory can be read at all is decided before it is walked
- extension_dir is not set, lies outside open_basedir, or is not a readable directory
- The reason travels with the verdict and names both paths, so the answer can be acted on
- None now means every module on disk is loaded, and N/A means the directory was never read
The block follows the rules of the project again: names without underscores, no single line if and else in braces, no blank line inside the body
- A plan for the journals and the data around them (docs/PRIVATE-DATA-2026.md):
Batch 1: no secret reaches a journal
- The login report stops printing the password it is handed on a failed attempt
- The request dump passes through the masking rule Logger already owns, and records the key
names of the session and the cookies instead of their content
Batch 2: the installation proves its private directories are private
- A marker fetched over its own address and judged by the body, because a status code decides
nothing where errors are mapped onto a page
- Three verdicts and never two: closed, open, and a transport that could not run
Batch 3: a server configuration in the delivery, held to the tree by a test that enumerates the directories declaring themselves private and demands the fragment cover each of them
- Batch 4: the runtime tree out of the document root, as the migration of a major release
Batch 5: one prefix per meaning in the journal names
- error_ is what the system could not do, log_ is what happened
- The file journal splits into log_file.log for every operation and error_file.log for the
error level alone, hack.log becomes log_hack.log and warn.log becomes log_warn.log
- The routing follows the levels that are already set, so no record is classified twice
Benefits:
- A field of the dashboard stops answering a question nobody asked it
The decisions taken about the journals are written down with their reasons instead of living in a conversation
Technical notes:
- No behaviour of the site changes: the monitor reports differently and the document adds no code
The plan names functions, files and constants rather than line numbers, so it survives the next edit of the files it points at
The label map of the security section keeps the retired keys, so journals already on disk do not lose their names when batch 5 lands
The structural validators of the upload service walk a container to a bounded depth, and three of them refused a file the moment that bound was reached. An ordinary tar of 300 members, a fragmented MP4 and a gzip file of concatenated members were rejected on a build without Fileinfo, while the magic database accepted all three.
Core changes:
- Bounded walks that answer at their bound (core/classes/upload.php):
checkTarBody() reads the two closing zero blocks from the end of the file
- Where the walk reaches the marker it still reads both blocks itself, so a half marker followed
by junk, a missing marker and data running past the end of the file all stay refused
- The bound alone ends the walk, so the member count of an archive no longer decides its type
checkBoxBody() answers the declared track at the bound, so a file of more boxes than the walk visits is proven by its file type box, its brands and the handler of its track
checkGzipBody() walks a gzip file member by member
- getGzipHead() reads the RFC 1952 header of one member at any offset of the file
- getGzipStop() takes the end of a stream from inflate_get_read_len(), holds the member against
its own checksum and length and returns the offset the next member begins at
- Bytes behind the last member that open no header are refused, as is a member whose trailer
disagrees with its stream
- Regression fixtures (tests/Support/upload_probe.php, tests/Unit/UploadFallbackTest.php):
New bulky scenario: a 300 member tar, a padded archive, a 403 box MP4 and a concatenated gzip are published, each under its own extension and with the canonical type of its format
Their corrupt counterparts stay refused: a broken header inside the walk, a missing end of archive, a half marker, no marker at all, many boxes without a track, a broken second member and trailing bytes behind the last one
- Help revision (admin/info/uploads/ru.md):
New section on how the type of an upload is decided
- Fileinfo is the preferred reader and belongs on the server; the structural walk answers only
where it is absent, does not open or cannot name the file
- A type the database named against the extension is refused rather than asked a second time
- One table of what each of the 21 formats has to prove, one of the components a server needs,
and one of the four capability reasons error_file.log distinguishes
Two corrections of the catalogue description
- The upload rule belongs to the module of the open directory, not to the record of all, and the
quota of that module does not limit the administration
- The template variables list [quot] and the real order behind [width], [height], [tsrc] and [rel]
- Task file (docs/UPLOAD-FILEINFO-PLAN-2026.md):
- Removed, because the plan it carried is implemented and verified
Benefits:
- An ordinary long archive is published again on a build without Fileinfo
- A disguised, truncated or corrupted container is still refused by the structure it claims
One page names the preferred reader, the components a server needs and the two different reasons an upload can be refused for
Technical notes:
- Routes, JSON, the eight key result shape and the database are unchanged
- No new required extension: inflate_get_read_len() belongs to ext-zlib, which stays a suggestion
Verified on a build with the extension and on one without it: the suite, every one of the 21 formats, real world archives, and the HTTP flows of the catalogue, the editor and the frontend
The upload service gains one structural validator per format, so a PHP build compiled without Fileinfo publishes every one of the 21 allowed types by walking the container instead of trusting a name. The same commit carries the word limit of comments, private messages and the forum, where zero now bounds no word instead of refusing every body.
Core changes:
- Content validation without Fileinfo (core/classes/upload.php):
The magic database stays the first reader and the fallback answers only where it cannot
- Bounded fopen, fread and fseek, so a file of any size is judged without being held in memory
- Fifteen validators: image type marker, two consecutive MPEG frames, RIFF chunk chain, FLAC
metadata blocks, Ogg page checksum and codec, ISO BMFF brands with the track handler that
separates m4a from mp4, EBML doctype, PDF header and trailer, zip central directory, RAR block
chains, gzip stream against its own CRC and length, 7z start header, tar header checksums
- A type the database did name is answered unchanged, so a conflict with the extension is refused
rather than asked a second time
- The check order is fixed: missing, size, transfer and extension answer before anything is read
getTypeReader() becomes protected, which is the seam a test closes while every other extension of the machine stays available
- One validator for both flows (core/classes/upload.php):
- getFileMime() takes the verified extension and answers the canonical type of the format
- addRemoteFile() no longer refuses a whole build over the type reader; cURL keeps its own check
- A missing capability told apart from a refused file (core/system.php, modules, lang):
- unsupported gets the neutral _ERROR_SERV text in all six locales
- The three inline mappings of the modules now read the one getUploadFailText() resolver
- error_file.log distinguishes fileinfo_missing, fileinfo_init_failed, decoder_missing, curl_missing
- Word limit of zero (core/classes/comment.php, core/classes/privat.php, modules/forum):
- Zero bounds no word instead of refusing every body, and the longest word is counted in characters
- The forum setting becomes a number field with its hint instead of a yes or no switch
- Tests (tests/Support/upload_probe.php, tests/Unit/UploadFallbackTest.php):
- Every non-image fixture is structurally complete now, checksums included
New scenarios: each format without a magic database, the matrix of one body under every foreign extension, corrupted and polyglot bodies, the refusals that come first, and the remote half
Benefits:
- A production build without Fileinfo publishes uploads again without weakening content validation
- A disguised, truncated or corrupted file is refused by the structure of the format it claims
- One mapping explains a refused upload on every screen
Technical notes:
- Routes, JSON, the eight key result shape and the database are unchanged
- ext-fileinfo, ext-zip and ext-zlib move from require to suggest in composer.json
- The suite passes on a build with the extension and on one without it
The document stopped being a briefing that asks the reader to confirm a problem and became the task itself: the cause is named, the four points that produce it are given with file and line, and every one of the twenty-one allowed types gets the structural check its fallback must pass.
Core changes:
- Statement of the task (docs/UPLOAD-FILEINFO-PLAN-2026.md):
the constraint is stated first: a clean SLAED without Composer or vendor
- routes, JSON, result shape and the database stay as they are
- content validation is not weakened, and GD and cURL stay required where they already were
- the cause is named: production PHP 8.4.22 built with --disable-fileinfo
- The points that produce the refusal:
- checkUploadInput() answers unsupported before input, size and extension are looked at
- the same read blocks remote upload
- getFileMime() answers null when finfo is absent or cannot be created
- the route shows unsupported as a format error
- The filename contract:
the extension comes from the submitted name and the content from the temporary path
- the fallback never reads the type off the name the file is finally stored under
- The work itself:
- the order of checks is fixed so a missing Fileinfo cannot mask missing, size or transfer
- one content validator serves local and remote upload, reading through fopen/fread/fseek
- a table gives the required structural check and the canonical MIME for each allowed type
Benefits:
- the reader of the plan can start writing code instead of first reproducing the fault
- a per-format table makes the acceptance of the change checkable rather than arguable
Technical notes:
- documentation only: no code, schema or configuration is touched by this commit
Every string this page shows stands in a chip, a shelf note, a card or a counter, and never inside a sentence, so each one begins with a capital and drops the words its box cannot carry. The forward prefix stops being an untranslated abbreviation and becomes the noun each locale really uses, the way the reply prefix already did.
Core changes:
- The chips of the compose header (lang/*.php):
- _PRWAIT reads «Интервал 60 сек.» where it read «интервал между отправками 60 секунд»
_PRLIMTO and _PRLIMSUB name the field and the bound and nothing else
- «Имя, до 25 символов» and «Заголовок, до 100 символов»
- all three now stand on one line at 1240 and 800 and on two at 390, where they wrapped
- The recipient card (lang/*.php):
_PRBOXFULL, _PRBOXNEAR and _PRBOXROOM drop «получателя»
- the name of the account stands one line above them in the same card
- The forward prefix (lang/*.php):
_PRFWD was the untranslated Fwd in all six locales
- it becomes «Пересылка», Forwarded, Weitergeleitet, Transfert, Przekazane, «Пересилання»
- a forwarded subject now reads «Пересылка: …» the way a reply reads «Ответ: …»
- Capitals where a string stands alone (lang/*.php):
_PRFREE, _PRSHOWN and _PRNOLIMIT, plus the Polish _PRFOCUSN
- an outbox holding nothing prints _PRNOLIMIT with no counter before it, so it starts a line
- the counter prints _PRSHOWN alone whenever no search is running
Benefits:
- a chip says what it means without being cut or wrapped
- nothing in the interface is left in an abbreviation only one language reads
Technical notes:
- values only: no constant is added, renamed or retired, and every placeholder is preserved
- all six locales carry the same wording in the same change
- docs/PRIVATE-MESSAGES-2026.md records the pass and the widths it was measured at
Batches 5 and 6 of docs/PRIVATE-MESSAGES-2026.md finish the two-column mailbox: the compose form answers with a recipient card that grades the mailbox a message would land in, and a focus deck puts the unread of both received folders above the list instead of on page four of one. The same pass corrects a quota that refused sends it had no business refusing and one that refused every send whenever the setting was zero.
Core changes:
- Recipient card and the bounded autocomplete (core/system.php, core/helpers.php):
getUserCardData() resolves one exactly matched name and grades the mailbox it would land in
- the grade folds getPercentTone() into room, nearly full and full, and never shows a number
- a mailbox no setting bounds is graded none rather than well
getUserList() answers a richer shape behind rich=1 and bounds both branches
- {items, card} in one round trip, card filled only when the name resolved exactly
- the rich branch answers 10 and the flat one 50, where it answered 1246 for a single letter
- getTplUserSearchInput() takes the card container a field opts in with
- Focus deck over the inbox (core/user.php, core/classes/privat.php):
- getRecentList() takes an unread argument reading the cabinet badge predicate across both folders
- getNewWhere() is that predicate, written once for the badge and the deck instead of twice
getPrivatFocus() builds the deck, which renders over the inbox alone as the demo draws it
- every slot addresses the inbox and carries the page, the search, the filters and the sort
- the inbox action set gains unsave, because the deck shows saved messages never read
- Forwarding and the compose state (core/user.php, templates/lite/partials/privat-view.html):
the compose view reads id and fwd, loads the source under the reader's own predicate
- an empty recipient, a localised prefix and the body without the quote wrapper
- the compose state names what bounds a send: the interval and the two stored lengths
- Quota corrections (core/classes/privat.php):
checkQuota() no longer refuses on the recipient's saved folder
- an arriving row carries no saved flag, so it can only land in the inbox
- the saved quota stays enforced in setMessageSaved(), where a message is moved into it
both quota reads test the bound before comparing against it
- a setting of zero is the absence of a bound, as the outbox, the ring and both alerts read it
- count >= 0 used to be true forever, so a zero setting refused every send and every save
- Out-of-band discipline (core/user.php):
the row travels back only where the caller stands on it, which the list row asks for
- the deck and the deep link do not, because a swap with no target is an error and not a no-op
Benefits:
- a full mailbox is visible before the send and not only in the refusal that follows it
- what is unread is reachable from the top of the list rather than through the pager
- a suggestion list is bounded, so a large membership cannot answer a page of a database
- one predicate behind the badge and the deck, so a counter and its cards cannot drift apart
Technical notes:
- no schema change: setup/sql is untouched and batch 7 is closed on its measurement
- the flat autocomplete keeps its array shape for the twelve forms that read it, bounded at 50
- span.html and input.html gain additive keys only; no existing caller renders differently
- ten strings restyled and _PRFWD translated across de, en, fr, pl, ru and uk
- PHPStan clean, 819 tests, PHP-CS-Fixer clean; the two quota tests fail against the unfixed code
The private message page no longer redraws a whole tab for every action. A reader opens a message beside its list instead of in place of it, narrows the mailbox from a toolbar above it, and reads the quota off a ring on each shelf rather than off an alert that fired from half capacity.
Core changes:
- Private message subsystem (core/classes/privat.php):
The list answers a selection instead of a whole mailbox
- getPickWhere() lays the state, period and search conditions over the box predicate
- one placeholder per column and a declared ESCAPE, because prepares are native here
- the period binds a number of days, so the boundary is drawn by the server that wrote the row
- getBoxFacets() answers every chip number in one aggregate row
- getSortOrder() picks from four fixed shapes, each closing on a unique tie-breaker on the id
- getUnreadBoxCount(), getBoxLimit() and getBoxFill() answer the shelves and their rings
- The list row carries a snippet the server cuts, and getMessageView() answers the same cut
- Routes and adapters (core/user.php, modules/account/index.php):
- privat() loses the four htmx tabs and reads id, typ and uname
- getPrivateMessageView() answers one column: a mailbox, a message or the empty pane
- setPrivateMessageRead() answers the reading pane, with the row and the counters out of band
- getPrivatPick() reads the selection under fixed names and reduces it to fixed values
- getPrivatRowData(), getPrivatShelves() and getPrivatBadges() own the row and the counters
- The page number leaves cid and travels as pnum, so cid keeps its two meanings and gains no third
- Templates (templates/lite):
- partials/privat-page.html, privat-list.html, privat-view.html and fragments/privat-row.html
- One swap boundary each: the shell, the left column, the reading pane and a single row
- The shelf strip, the toolbar, the filter chips and the shared sl-knob ring
- Client behaviour (plugins/system/slaed.js):
- The pane switch and the back button of the narrow layout
- The reply form filled through the editor API from a hidden carrier textarea
- The filter chips, each group holding its value in one hidden field
Benefits:
- Opening a message keeps the scroll position, the checkbox selection and the editor instance
- The list, the filtered count and the pager come out of one predicate set inside one answer
- The shelves and the chip facets answer the mailbox and stay independent of the toolbar
- One tone ladder serves the quota rings, the server gauges and the debug panel
Technical notes:
- No schema change and no new route; every state-changing route stays behind POST and the token
- Row and reading-pane actions send no body at all, so the selection form cannot override them
- Every new language constant is present in all six locales
- The search stays a scan of one mailbox: EXPLAIN answers ref on in_box and never a table scan
- docs/PRIVATE-MESSAGES-2026.md records which batches landed and what was measured
- docs/UPLOAD-FILEINFO-PLAN-2026.md is an unrelated plan document carried in the same tree
The two-column layout demo grows the two shelves it was missing and becomes a specification a fresh session can implement from: every decision it implies is written down next to the code that has to carry it. The counters and flags the layout reuses are fixed in the theme itself, so the fix reaches the cabinet and the mailbox that use them today.
Core changes:
- Missing shelves and their contract (demo/private-messages-final-2026.html):
Sections for the saved box and the compose form, both with their own states
- Saved: its own quota ring, bookmark flags, unsave in the row and in bulk
- Compose: recipient autocomplete, the card with the recipient quota ring,
the real Toast UI, and all five answers the send route can give
A saved message no longer appears in the inbox, because the box predicate excludes it, and the shelf badges split the unread between them
The backend contract records what the layout demands: one main swap with addressable out-of-band targets, a snapshot page, the editor filled through its own API, the two-step deep link, and the mobile pane switch
Counters and state flags (templates/lite/assets/css/theme.css, templates/lite/fragments/span.html):
sl-chip-icon turns a label-less state flag into a round badge, and the fragment emits it only when the flag carries no text, so the chips that do carry a label keep their pill
sl-cab-badge and sl-pm-new centre their digit and declare border-box: one digit was sitting against the left padding inside a 32px oval
- Unread badge (core/user.php):
getUserNavItems() caps the counter at 99+, because the badge is a circle on a tile and a four-digit number stretched it across the label
- Work plan (docs/PRIVATE-MESSAGES-2026.md):
Seven batches from the data contracts to the conditional search index, each with its own verification, plus the predicate table every list query must match and the four questions that must be answered before their batch
Benefits:
The redesign can start in a new session from one document instead of being re-derived from a demo
- Every counter in the theme is centred by the same method the chips use
Three audit rounds are answered in the plan: native prepares, the flat autocomplete contract, POST-only opening and snapshot parity
Technical notes:
- No schema change, no new SQL, no route touched
The plan defers the search index to its own approved batch, because a BTREE index cannot serve a leading wildcard and FULLTEXT changes search semantics
Adds design prototypes for the account private-message page: eight distinct approaches, the variant selected from them, and three labs that settle the quota indicator and the filter toolbar. No runtime code, template or stylesheet is touched; every page is a self-contained artefact under demo/.
Core changes:
- Eight approaches (private-messages-v1-mailbox.html ... v8-flow.html):
One page per approach: mailbox, split view, dense admin register, cards, messenger, minimal, high-volume and an experimental flow concept
Each page covers inbox, outbox, unread, selected message, sender, subject, date, status, open/reply/delete/mark actions, search, filtering, paging, empty state and a mobile rendering
- Mobile layouts run on @container, so the phone frames are the real narrow
rendering rather than a picture
- The message field mounts the actual Toast UI editor from
plugins/editors/toastui instead of a placeholder textarea
- Chosen layout (private-messages-final-2026.html):
- Two-pane base: the list keeps its place while a message opens beside it
Folder tiles carry an SVG quota ring in place of the text alert; the arc and the icon share one tone
- Folders without a quota, the outbox and compose, draw a solid ring instead
- pathLength="100" normalises the arc, so one percent is all a template needs
The filter strip became a control: chips toggle, so a cleared filter can be restored, and read/unread exclude each other
- Reading toolbar keeps message actions together and moves navigation aside
Decision labs (private-messages-quota-lab.html, quota-halo.html, toolbar-lab.html):
Thirteen quota indicators, each in three states, all built from mechanics the theme already owns
- The refined ring with its thickness, size and cap alternatives
- Six filter-toolbar layouts with their trade-offs
- Index (private-messages-index.html):
Description, benefits and drawbacks of every variant, a comparison table and links to all pages
- Records what the live page revealed and the contract each block would need
Benefits:
- The layout decision rests on the running site rather than on assumptions
Reuse over invention: tables, chips, dial, pager, bulk bar, alerts, progress lines and rings all come from templates/lite
Every page states what the backend must supply, so integration can start from a written contract instead of a redrawing round
Technical notes:
- Demo only: no PHP, template or theme file is modified
Extra CSS lives inside each page under its own prefix and never reaches the theme
- Pages load repository assets only; no CDN and no new dependency
The chip set carries no attachment filter: _privat has no such column and a LIKE over the message body would scan the whole table
sl-knob exists only in templates/admin; using it on the site would require moving that block into templates/lite first
- Backward compatible: nothing outside demo/ is affected
The page-cache generation counter lived inside storage/cache/pages, the directory the manual cache clear empties, so the clear reset the counter every stored page is keyed by. It now sits with the other persistent counters in storage/counter, and the clear advances it instead.
Core changes:
- Page-cache generation (core/classes/cache.php):
getEpoch() and addEpoch() read and write COUNTER_DIR/cache.log
- the counter is state, not a cache artifact: deleteAll() and the cache size report no longer reach it
- the exclusive lock, the single bump per request and the zero on a missing file are unchanged
deleteAll() ends on a bump of its own
- unlink() reports failure silently, so a page the sweep could not remove has to stay unreachable
- the directory guard moved into the body, so the bump runs even with no cache tree on disk
- Test bootstrap (tests/bootstrap.php):
- COUNTER_DIR is defined next to CACHE_DIR and LOGS_DIR, so the harness matches the constants core gives the class
- Documentation (docs/PAGE-CACHE-ROUTES-2026.md, admin/info/config/ru.md):
- the invalidation section names the counter file and the bump the clear performs
- the admin help gains the invalidation rule: admin writes, comment operations, post and vote from the front
- the cache structure list gains storage/cache/pages/locks, which appears only under cache_l
Benefits:
- a cache clear can no longer resurrect a page whose file survived the sweep
- the counter stops being counted and swept as if it were a cached file
- the admin panel now explains why the page cache retires itself after an edit
Technical notes:
- storage: the counter is storage/counter/cache.log; the documented cache clear on upgrade removes the obsolete storage/cache/pages/epoch
- interface unchanged: getEpoch() and addEpoch() keep their signatures and all twelve call sites
- backward compatible: an installation without the file reads zero, exactly as before