Vydání
Přehled změn
Co se změnilo v každém vydání frameworku WBengine: nové funkce, změny, opravy a poznámky k upgradu, od nejnovějšího vydání. Poznámky k vydáním jsou psané anglicky.
Tento web běží na WBengine v6.7.1
v6.7.1 2026-11-02
Opraveno
The
homepageof the Composer package pointed to a domain that the project no longer uses, and Packagist showed it as the home page of the framework. It is nowhttps://www.wbengine.org, the live skeleton application.A built
build/wbengine-mcp.phar(version 6.3.0) was committed by mistake althoughbuild/is ignored, so it shipped inside the Composer package of every release. It is no longer tracked. The release workflow builds the phar for each release and attaches it, as before.
Dokumentace
The README links the skeleton repository, the live skeleton application at
www.wbengine.org(with its documentation, blog, changelog and performance page) and the repository of the central administration.
v6.7.0 2026-11-01
Přidáno
The Integrations page tells where the MCP server file goes on Linux, macOS and Windows, that PHP 8.3 or newer must be on the PATH, and that on Windows the recommended way is WSL2. CI starts the built MCP server from Node on Linux, macOS and Windows runners, with a quiet minute in the session (
tests/mcp-client/session.mjs).Wbengine\Version::current()and the layout variablewbengine_version: the version of the running framework (6.7.0), read from Composer, ordevwhen it is not an installed release.Views per language. Migration
6.7.0/013-analytics-locale.sqladds alocalecolumn tocms_analytics_dailyandcms_analytics_totals(part of the primary key, safe to rerun, leaves a site without analytics alone).Analytics\Trackerrecords the language of the request, and a cached page carries its language in the stored metadata, so a cache hit counts in the right language. The totals stay the sum of all languages./api/v2/analytics/overviewaddsper_localeandby_localeand takes an optionallocalefilter (400invalid_locale, 409locale_unavailablewithout the migration). Rows counted before the migration have an empty language and are reported asnull, never assigned to a language. A site that upgrades the framework and has not applied the migration keeps counting exactly as before (Analytics\Schema::hasLocale()probes the table once per process).doctorandmigration:statusreport migration 013 when analytics is enabled.MCP server:
get_statisticstakeslocaleand shows the split by language,site_reporthas a section By language, both say when views have no language or the site has no split.
v6.6.0 2026-11-01
Přidáno
Wbengine\Seo\Crawlers::register($app)adds/robots.txtand/sitemap.xml, generated for each request and opt-in. The sitemap lists the pages that can be opened by their link (visible, public, without a hidden article) once per language withhreflangalternates, withlastmodfrom the newest published article of the page.robots.txtnames the sitemap. Settingsseo.allowIndexing,seo.disallowandseo.sitemapExclude(Config::getSeoSettings()). No migration.MCP server: the guide topic
addresses_and_seosays what the sitemap lists and that publishing is enough.
v6.5.4 2026-11-01
Opraveno
MCP server: it stopped by itself after 60 seconds without a request. A client that starts it over a socket pair (Claude Code, built on Node, does) puts PHP's
default_socket_timeoutof 60 seconds on the input, and the read timeout was taken for the end of the input, so the server loggedinput closed by the clientand exited while the client still showed it as connected. The input timeout is now raised to a year and a read timeout no longer ends the loop; only a closed input does. A regression test runs the built phar on a socket pair with a one second timeout.
v6.5.3 2026-10-31
Opraveno
MCP server, found when it was used for a real article: the generated address no longer splits a word at an apostrophe (
humanity-sis nowhumanitys), is at most 50 characters of whole words and never ends in a function word; the browser title and the menu name are cut at whole words and never end in "and", "the" or "into", the menu name is the part of the title before a colon when that is short.check_articlewarns when the page or its menu entry lacks the translation for a site language (the Czech page kept an English title and description).site_reportwrites "no comparison" instead of a misleading change when the period before starts before counting began.
v6.5.2 2026-10-31
Změněno
MCP server: it says on stderr how it ends:
input closed by the client after N s and M messageswhen the client closes the input andfatal errorwith the place when PHP fails, plus onestartedline with the process id and the versions. A process that disappears without either line was stopped from outside by a signal. Claude Code keeps the stderr of a server in its MCP log, so a disconnect can now be explained. Nothing else changes.
v6.5.1 2026-10-31
Opraveno
MCP server: the text of a tool result is also the
messageof itsstructuredContent. Claude Code shows the model only the structured part when there is one, so the guide texts, the Markdown report, the warnings and the marking of site text as data never reached it.
v6.5.0 2026-10-31
Přidáno
MCP server:
get_statistics,site_report(Markdown report with the change against the period before and the limits of the numbers) andanalyze_activity(the change log by actor, resource, action and day, repeated edits, refused writes by error).list_changestakesactor,action,since,untilandoutcomerejected. Promptsweekly_reportandfind_problems, guide topicstatistics. A menu entry created bysave_article_draftgoes after the last entry instead of at the position 100. No framework change:GET /changesalready had the filters and the refused writes. Seeref-mcp.md.
v6.4.1 2026-10-31
Opraveno
MCP server: requests to a host with an IPv4 address go over IPv4 first. PHP streams try IPv6 first and waited several seconds on a network whose IPv6 route is broken, which made every tool call about seven seconds slower. The host name and the certificate check are unchanged; without an IPv4 address, or when IPv4 fails, the default behaviour is used.
v6.4.0 2026-10-31
Přidáno
MCP server for an editor who is not technical. Tools
wbengine_guide(how the site works, shipped as texts and also offered as MCP resources and prompts),plan_article_pageandcheck_article(address, browser title, description, article box, menu, languages;publish_articlerefuses a page that cannot show anything and reports what is still open),update_page,list_menu,set_menu_entry,list_sections,list_box_types,list_boxes,add_box,update_box,hide_box,show_box,upload_image,list_images.save_article_draftwithnew_pagefills the SEO fields and attaches the box that shows the article. Tools are offered only when the key has their scopes;site_infosays whether new article pages can be created. Nothing is deleted. Seeref-mcp.md.The Integrations page offers the preset "Editor with structure and statistics" (Content, Publishing, Structure and Images write, History read, Analytics).
v6.3.1 2026-10-31
Opraveno
MCP server:
save_article_draftandpublish_articlewarn when the page of the article has no box that shows articles, which would leave the published address empty. A page created bynew_pagehas none; the warning tells the agent to use a page that already shows articles or to ask the administrator for an article box.
v6.3.0 2026-10-31
Přidáno
MCP server (
Wbengine\Mcp,bin/wbengine-mcp.php, also built as the single signed filewbengine-mcp.pharand attached to every release with its checksum): stdio, speaks the stateless revision 2026-07-28 and the revisions 2025-11-25 and 2025-06-18 withinitialize. Tools for one site:site_info,list_pages,list_articles,get_article,save_article_draft,preview_article,publish_article,hide_article,unhide_article,list_changes. It is a client of API v2 with verified TLS, no redirects, size and time limits; text from the site is returned marked as data;WBENGINE_MCP_MODE=draftremoves the publish and hide tools. Seeref-mcp.md.The Integrations page offers presets for the permission matrix (Full management, Agent for content, Agent drafts only, Read only) and shows a client configuration (JSON and a
claude mcp addcommand) with the key once, after a key is issued.
v6.2.0 2026-10-31
Přidáno
Hiding articles:
POST /api/v2/articles/{id}/hideand/unhide(scopesarticles:writeandpublish:write,If-Match, idempotent, recorded in the change log ashideandunhide). A hidden article is not displayed, the page it belongs to is not found (404) and its menu item disappears; showing it again restores both. The article resource has ahiddenfield. Migration6.2.0/012-article-hidden.sql(also an index oncms_articles.site_id);doctorreports it asmigration.012. Without it nothing changes and hiding answers 409hide_unavailable.The article list can be searched by title (
search) and filtered (hidden=0|1,has_draft=0|1); the capabilities listarticlesamong the searchable resources.
v6.1.0 2026-10-31
Přidáno
Per-key address restriction: an integration key can be bound to IP addresses or CIDR ranges. The issue form has an optional field, every key card a form to change it, and the card shows the address the key was last used from. A call from another address is answered 403
ip_not_allowed. Migration6.1.0/011-integration-ip-restriction.sql;doctorreports it asmigration.011. Without it nothing changes.
v6.0.0 2026-10-30
Odstraněno (nekompatibilní změna)
The unused settings
codePage,cdnPath,cssFiles,ipAdmins,minimizeHtml,minimizeCssandminimizeJs, theirConfiggetters (getHtmlHeaderCharset(),getCdnPath(),getCssCollection(),getAdminIpCollection(),minimizeHtml(),minimizeCss(),minimizejs()) and theminimizeCsstemplate variable ofSite. Nothing in the framework read them. An existingSettings.jsonwith these keys still loads, the keys are ignored.
v5.2.0 2026-10-30
Změněno
The permission groups of the issue form are one row each with radio buttons None, Read and Write (a group with one level has one button, Analytics is a row too). A row is red for none, blue for read and green for write; the key list uses the same colors on its group badges. Levels of single-level groups are now
write(Publishing) andread(History, Analytics).
v5.1.0 2026-10-30
Přidáno
Permission groups when issuing an integration key: Content, Publishing, Structure, Images, Site administration and History, each with a level (no access, read, read and write), plus the Analytics choice. A key cannot be changed afterwards. The key cards list the groups instead of a count. Without a group field the full profile is still issued.
The Integrations box needs no class in the application: a
cms_boxesrow naming the moduleintegrationsis enough (Sectionresolves it toWbengine\Box\Integrations).integrations:installno longer writes a class or needs--rootand--namespace.
Odstraněno
The "Older key" marker of the key list, which cannot be told apart from a deliberately limited key.
Dokumentace
README brought up to date with 5.0: API v2 reference table and database updates, the complete configuration reference (
http,apiV2,i18n.baseUrl, page cache paths), command line tools includingintegrations:install, an upgrade guide from 4.x, and version-specific wording removed.
v5.0.0 2026-10-30
Odstraněno (nekompatibilní změna)
API v1 (
/api/auth,/api/users,/api/sections,/api/translations,/api/cache/flush) and the JWT login with refresh cookies. Users, sections, translations and the cache flush are API v2 resources;Api::Initialize(), the CORS and cookie settings ofApi,Wbengine\Auth,User::getJwtToken()andApiSectionModelare gone. Sign in on the web throughBox\Auth.The dependency
firebase/php-jwt: the framework has no runtime Composer package. Thejwtsection ofSettings.jsonandWBENGINE_JWT_*are ignored, anddoctorno longer checks them.The table
cms_refresh_tokens(migration5.0.0/010-drop-refresh-tokens.sql).The change log keeps
v1as a valid historic source of old rows; new rows usev2orcli.
Vydání před 5.0.0
v4.11.0 2026-10-30
Přidáno
integrations:installcommand: previews, and with--applycreates, the application class, page, section and box record for the built-in Integrations screen, never changes existing records or files, and reports what still blocks issuing keys.
v4.10.1 2026-10-30
Změněno
The integrations page lists active keys as cards (name, creation, last use, permissions, revoke behind a disclosure) with revoked keys folded into a closed section, and marks keys issued before the latest permissions as older keys.
v4.10.0 2026-10-30
Přidáno
API v2 users: list, read, create, change the role, disable and enable, issue a password link; scopes
users:readandusers:write. One-time password links (cms_password_resets, 48 hours, only the hash stored) are used on the login page (/login/?reset=TOKEN). Disabled accounts (cms_users.disabled_at) cannot sign in, get tokens or keep a web session. Migration4.10.0/009-remote-users.sql; without it nothing is disabled and nothing breaks.POST /api/v2/cache/flush(scopecache:write) andGET /api/v2/config(scopeconfig:read, secrets replaced). The fixed integration profile has 22 scopes; keys issued earlier gain the new ones by rotation.
v4.9.0 2026-10-30
Přidáno
GET /api/v2/changeswith the new scopeaudit:read(capabilityaudit.read): a paginated, filterable feed of the change log (resource,resource_id,action,source,actor,since,until) and, withoutcome=rejected, of rejected and failed writes, without before/after bodies.audit:readis part of the fixed integration profile (18 scopes); keys issued earlier gain it by rotation.
v4.8.0 2026-10-29
Přidáno
GET /api/v2/pages?search=(title or link) andGET /api/v2/boxes?search=(name): substring search with escaped LIKE wildcards, at most 100 characters, combined withpageandper_page. Advertised assearchin the capabilities.Box rows carry read-only
module,methodandrenders_articles; a page read carriesarticle_box_ids, the visible boxes that render articles. Unregistered legacy boxes count when their method isgetArticleBoxor their module isArticle. All are rejected on write like unknown fields.Complete change log as an engine feature. New
Wbengine\Audit\ChangeLogwrites every change tocms_api_changeswith actor, resource, action, before/after data and asource(v2,v1,cli). API v2 writes use it in the same transaction as before. API v1 user create/patch/delete, section writes, translation writes and cache flush, and the CLIuser:create|password|delete, are now logged too. Keys named password, token, secret, key or hash are redacted, passwords and hashes are never stored. Migrationmigrations/4.8.0/008-change-log-source.sqladdssource,local_user_id,cms_api_audit.detailandcreated_atindexes (idempotent, reported bymigration:statusasmigration.008); until it is applied the new columns are skipped and nothing else changes.Rejected API v2 writes (validation, not found, conflict, 412, 428) are recorded in
cms_api_auditwith outcomerejectedand the error code indetail. 5xx failures get a reference code inerror_log, in the response message and in anerroraudit row. No request body and no IP address.audit.retentionDaysandaudit.revisionRetentionDayssettings (default 365, 30 to 3650) and the CLI commandsaudit:prune [--days=N] [--dry-run](batched; keeps the newest change per resource and revisions referenced by drafts) andaudit:list [--limit=N] [--resource=X] [--actor=X].
Změněno
Box device limits are now enforced:
device_minanddevice_stricthide a box on devices below (or, when strict, other than) the minimum. The stored default (device_min1, not strict) still shows every box everywhere. The full page cache key now contains the device type (PageCache::cacheKey($path, $device)), so a page is cached once per device class.BoxRegistry::register()takes an optional$metadataargument (['renders' => 'article']) and the discovery catalog listsrendersfor each box type.
v4.7.1 2026-10-29
Změněno
All documentation (plans, references, guides) is in English.
v4.7.0 2026-10-29
Přidáno
Pages, menu items and articles in API v2 lists and reads carry
translation_locales, the languages that have a translation, computed with one query per page of results. A client can show "3 of 20 languages" without asking for every language of every record.
v4.6.0 2026-10-29
Přidáno
GET /api/v2/capabilitieslistsi18n.defaultandi18n.locales(the locales the site really serves), so a client can offer a fixed choice of translation languages instead of free text.
v4.5.0 2026-10-28
Změněno
The article
sourcefield is optional free text. API v2 accepts any text up to 255 characters; only control characters (\x00to\x1f,\x7f) are rejected withinvalid_source("Source must be plain text without control characters."). An empty, omitted ornullsource is valid. Previously anything that was not an http(s) URL, a local path or a fragment was rejected.The article box renders the source safely: an http or https URL, a local absolute path or a fragment becomes a link (external links get
rel="noopener noreferrer"), everything else (includingjavascript:anddata:) is escaped plain text.Article.tplshows the author and source line only for filled values: no empty "Source:" label or link, and no line at all without both. Template variablessource_text,source_href,source_externalandbylineare prepared byBox\Article::sourceView().
Opraveno
Url::getHostName()derived the scheme fromSERVER_PROTOCOL(the HTTP version), so it always returnedhttp://, for example in the Location of configured site redirections behind a TLS proxy. It now uses the same HTTPS detection as the rest of the framework, honouringhttp.trustedProxiesandhttp.forwardedHeaders.
v4.4.0 2026-10-28
Přidáno
Optional "Analytics" choice when issuing an integration key: adds
analytics:readto the fixed 17 scope profile (18 in total,PROFILE_SCOPESis unchanged and a key without the choice is issued exactly as before). It is offered only when analytics is enabled inSettings.jsonand the schema ofmigrations/4.0.0/006-analytics.sqlexists (Preflight::analyticsProblems(),Doctor::SCHEMA_ANALYTICS); otherwise it is disabled with instructions and the server refuses it. The permission list shows the real count, the key list marks keys with analytics, and a rotation presets the choice from the old key. Also the integration name explanation is now actually rendered.
v4.3.0 2026-10-28
Přidáno
User management CLI:
user:create,user:password,user:listanduser:delete(--emailor--examplefor RFC 2606/6761 demo addresses,--dry-run). Passwords are read from a no-echo terminal prompt or one line of standard input, never from an argument or the environment. Validation, hashing and token revocation reuseApiUserModel; deletion keeps at least one active administrator and removes refresh tokens and sessions in one transaction. Documented in the README, including the first administrator on a fresh install.
v4.2.0 2026-10-28
Přidáno
Local integration key management:
Box\Integrationsscreen (ADMIN role read from the database, POST only, action-bound CSRF, fresh password confirmation, one-time key in aprivate, no-storeresponse withReferrer-Policy: same-origin, scope descriptions in cs and en, preflight with operator instructions, revoke and rotation order). The issued profile is a fixed list of 17 scopes and never includesanalytics:read.User\PasswordConfirmation: rate-limited, constant-time password re-check for the session user, never logging the password.Api\V2\Model::list()(non-secret metadata only) andlast_used_at, written at most once per 60 seconds per key and never failing a request. Migrationmigrations/4.0.0/007-api-integration-last-used.sql(idempotent, no runtime DDL);doctorchecks it asmigration.007;tests/schema.sqlnow contains the API v2 tables.Api\V2\Preflight: read-only check of API v2 configuration, schema and HTTPS.
Opraveno
doctorandMigrationStatusnever detected a missing column (Db::fetchOne()returns column metadata, not a value) and the database check called an undefinedDb::setCredentials(). Both now inspect the schema read-only as documented.
Poznámky k upgradu
Apply
migrations/4.0.0/007-api-integration-last-used.sql(idempotent) before using the Integrations screen;doctorreports it asmigration.007.
v4.1.0 2026-10-27
Přidáno
Opt-in first-party analytics with daily aggregate page, article and box counts, long-lived totals, configurable timezone, bounded retention and no visitor identity.
Read-only
GET /api/v2/analytics/overviewfor integrations with theanalytics:readscope, including daily, weekly and monthly series and top content.analytics:pruneCLI command for bounded removal of expired daily aggregates.Public-page measurement start marker. API responses distinguish dates before measurement from dates with zero recorded views.
Poznámky k upgradu
Apply
migrations/4.0.0/006-analytics.sqlbefore enablinganalytics.enabled.Analytics is disabled by default. See the README for timezone, retention, administration scope and the regular
analytics:prunecommand.
v4.0.2 2026-10-27
Opraveno
Explicit locale switching now redirects without reusing a cached response.
v4.0.1 2026-10-26
Opraveno
Debug exception pages render actionable diagnostics through the framework debug template.
v4.0.0 2026-10-26
Přidáno
Deferred
Application::route()registration for common HTTP methods;get()delegates to it. Explicit true/false callback results complete or decline a route.Batched
SiteModel::getSiteIdsByUrls()resolves exact URLs using database collation, preserves input order and chooses the lowest site ID for duplicate links.Site::assignDiagnostics()explicitly assigns the existing server, DB-version and PHP-version template variables through the site's model and variable tree.Session-owned CSRF service with purpose-bound tokens, stable multi-tab forms, form/AJAX request validation and private/no-store cache handling. Application exposes the same lazy service; public routes without CSRF usage remain unchanged.
Bundled POST logout form through
Box\Auth::getLogoutBox().Lazy
Application::getRequestContext()with shared client-IP and HTTPS resolution. Explicithttp.trustedProxiesIP/CIDR andhttp.forwardedHeaderssettings enable trusted IPv4/IPv6 proxy chains and original-scheme detection for session/API/locale cookies and absolute locale links. Forwarded headers are disabled by default; invalid configuration fails closed.
Změněno
Application construction prepares services and normalizes i18n without CMS queries, box execution, cache HITs or redirect exits.
run()handles custom routes before cache and CMS;display()shares the lifecycle. Update bootstrap ordering in 4.0.Custom Vars/renderer setup and response policies are evaluated before page cache. Accepted routes bypass shared cache; debug is built during request handling.
Core HTML dispatch always uses the renderer adapter's
display()path and keeps rendered whitespace intact.minimizeHtmlis ignored; its getter remains as a deprecated compatibility API. Configure transfer compression at the webserver.Http::Json(true)parses the request body once instead of validating and decoding separately. Raw mode, associative/scalar results, parser errors and the distinction between valid JSON null and an error throughjson_last_error()are preserved.Site menu-parent selection shares one request-local batch lookup across menu items, with or without data cache. Hidden/strict parent and collation rules are preserved; warm cache still incurs at most one lookup statement for parent selection.
Runtime diagnostic template variables are now assigned automatically only in debug mode. Production site initialization no longer opens a DB connection just to obtain
dbinfo; templates requiring these values must request them explicitly.Web login uses shared CSRF tokens. GET logout now returns 405; POST logout requires its own purpose token. Invalid login/logout tokens return 403 before authentication.
Cookie API login/refresh/logout require POST, JSON and
X-WBengine-CSRF: 1. Clients must add this header; trusted credentialed CORS origins must be configured explicitly. Refresh stays stateless; bearer-authorized API actions are unchanged.Request helpers return PHP-parsed values unchanged.
Post()/Get()without a field name return their complete input map; missing keys return null.getCookie()returns raw strings and rejects arrays. String-onlyPostString()/GetString()accessors provide explicit shape validation;PostRaw()remains a compatibility alias. Application code must validate fields and escape at output.Both renderer adapters now HTML-escape by default. Set
renderer.autoEscapeto false only for a temporary legacy migration, and mark trusted HTML explicitly.
Odstraněno
Http::secureClean(): the legacy slash/tag/HTML transformations are no longer applied to input. See the request-data migration notes before updating older apps.
Opraveno
run()/display()no longer dispatch duplicate layouts; display handles explicit empty/zero content. Missing CMS pages do not run shared boxes, empty sections work, and explicit 410 responses cannot be replaced by cached 200 output or a 404.Locale-prefix normalization also applies to a pending canonical redirect, letting application routes respond first. Existing static Router/API box dispatch remains.
Removed whole-document whitespace collapsing that damaged preformatted text, textarea values, inline JavaScript/CSS, attributes and JSON script strings. Clear old HTML/page caches during upgrade to remove previously damaged responses.
Parent menu links ending in
/now match the URL parser's bare prefixes when no exact bare link exists. Empty prefix sets no longer warn; missing or nonpositive site IDs cannot activate an orphan menu entry. Duplicate-link selection is stable.Token-bearing responses cannot be stored in public or user native fragments or page cache. Session-ID regeneration and session destruction invalidate CSRF tokens.
Missing or invalid connection addresses no longer fall back to spoofable forwarding headers. IPv6 addresses are canonicalized, and both session and API Secure cookies follow the same explicit trusted-proxy policy. Review session/rate-limit changes when deploying behind a proxy; see the configuration and migration notes.
Login CSRF and CAPTCHA fields reject non-string input without PHP type errors; stored CSRF tokens are checked before constant-time comparison.
Native output escaping now follows the final modifier of the complete expression; modifiers inside method arguments, array keys or concatenation operands no longer suppress escaping of unrelated output. Earlier escaping does not trust a later fallback.
Native compile checks detect same-size source edits with unchanged timestamps. Disabled production compile checks skip the checksum.
Smarty template identity includes escape mode so switching that setting cannot reuse an incompatible in-memory template. Native fragment identity includes compiler version and escaping mode.
Auth::setIssuedTime()updatesiatwithout overwriting the JWT issuer.New passwords are refused when the database column cannot hold a modern hash, instead of silently falling back to MD5. Legacy password verification is retained.
Native template fragments are separated by locale. Existing fragment entries are not reused across the change.
Shared page cache rejects private/no-store/no-cache responses, cookies, unsupported
Varyheaders and nonce-based CSP. Requests with custom cookies or personalized anonymous sessions bypass it. Cached security headers are restored on hits and 304 responses; policy supplied by the current deployment takes precedence. Records created before the stricter storage format are ignored.
Přidáno
Native fragment cache scopes:
public(default) anduser, with a lazy identity resolver integrated through Application and Renderer. Personal fragments separate users and locales, do not cache guests, and prevent shared page/public-wrapper storage.Exact
cache.pages.allowPathsanddenyPathspolicies, plus request-widePageCache::bypass(); deny rules take precedence.A persistent modernization tracker with task IDs, dependencies, verification requirements and the agreed WBengine OOP architecture constraints.
v3.5.0 2026-10-24
Přidáno
Absolute
hreflanglinks: the layout variablehreflangs(every language plusx-default),I18n::origin(),I18n::hreflangs()and thei18n.baseUrlsetting. Lighthouse and search engines reject relativehreflangURLs. WithoutbaseUrlthe address is taken from the validatedHostheader and the page cache keeps one copy per host.
v3.4.0 2026-10-24
Přidáno
Native template engine: an output tag may start with a quoted text and a modifier,
{'/blog/'|lurl}or{"x"|upper}(Smarty accepts this too). A{followed by a quote and no modifier, such as a JavaScript object, is still plain text.
Opraveno
The bundled article template printed a stray "Wbeigine" before every article title.
An error page (404, 401, 410, 500) had an empty
<title>: the title was set after the template variables were stored.Site::setHtmlTitle()now keepsmeta.titlein step.
v3.3.1 2026-10-24
Opraveno
The bundled
error.tplis translated (wb.error.*):Renderer::getErrorBox()now assignserrorand the labelstto the error templates.
Přidáno
LICENSE.md(MIT, as declared incomposer.json).
v3.3.0 2026-10-24
Internationalisation
Database content can be translated: site titles and meta tags, menu names and articles. One table per kind (
cms_site_translations,cms_menu_translations,cms_article_translations), the default language stays in the original columns, and a missing or empty translated field falls back to the default, field by field. NewWbengine\I18n\ContentTranslations, applied inSiteModel,getSiteMenuandArticleModel. No extra query in the default language or withi18noff. Sites and menu are cached per locale; articles are read by id. Without the new tables the application logs once and stays in the default language.REST API for editors:
GET,PUTandDELETE /api/translations/{site|menu|article}/{id}[/{locale}](roleeditor), validating locale, fields and length, flushing the cache on writes.Tests: 58 new (777 in total).
v3.2.0 2026-10-24
Internationalisation
User interface translation, off by default (
i18nsetting). NewWbengine\I18nwithTranslator,Plural,LocaleResolverandFormat.- The locale of a request: a language prefix in the URL (
/cs/blog/, removed from the URL before the rest of the framework reads it; the default language redirects to its unprefixed canonical URL), then thewb_localecookie, thenAccept-Language(with quality values), then the default.Content-LanguageandVaryare sent. - Catalogs are PHP arrays (
src/Locale/cs.php), looked up with a fallback chain (pt_BR,pt, default, the key).%name%parameters; plural forms by the CLDR rules of Czech, Slovak, Polish, Russian, Ukrainian, English, German, French, Portuguese and the languages without plurals. - Templates: the
{t 'key' name=$x count=$n raw}tag (native engine) and the modifiers|t,|lurl,|number,|ldate(native engine and Smarty). Translated text is escaped; withrawthe parameters are escaped individually. Template variableslocale,langandalternatesfor a language switcher andhreflanglinks. - The page cache key includes the locale: verified that a page cached in one language is never served in another, and that the ETags differ.
- The login and article boxes are translated (English and Czech catalogs in
I18n/messages/, keys start withwb.); applications can override them. - Number and date formatting without the
intlextension.
- The locale of a request: a language prefix in the URL (
Removed the unfinished
Localeclasses (Locale,Locale\*) andSession::getLocale(); nothing used them and their model called methods that do not exist. The tablescms_localesand thecms_users.localecolumn are untouched.Application::getLocale()now returns the locale code of the request (a string).The login form posts to a localized URL, its labels are translated, its
labelelements point at the right inputs, and its AJAX and form messages are translated.Found by the tests:
Translator::normalize()accepted"cs\0"(trim()removes NUL bytes);Format::date()warned on a non-scalar value, which the error handler turns into an exception.
v3.1.0 2026-10-23
Breaking changes
PHP 8.3 or newer is required (was
^7.2 || ^8.1).firebase/php-jwtmoved from 5 to 7. Decoding usesFirebase\JWT\Key.JWT signing keys are no longer built in. Configure
jwt.accessKeyandjwt.refreshKey(at least 32 characters) inSettings.jsonor set the environment variablesWBENGINE_JWT_KEYandWBENGINE_JWT_REFRESH_KEY.Wbengine\AuththrowsAuthExceptionwithout them.The reCAPTCHA secret is no longer built in. Configure
recaptcha.secretKeyorWBENGINE_RECAPTCHA_SECRET. Without it the captcha check fails.Db::createUpdateQuery()is removed. UseDb::update().Db::fetchRow(),fetchOne(),fetchAll()anddumpAllQueries()are now static, asDbInterfaceand every caller already assumed.SiteModel::getUrlId()is replaced bygetUrlCandidates(), which returns an array.User::login()no longer hashes the login and password with MD5 itself, andUser\Model::authenticate()looks the user up byemail.Refresh tokens are now stored on the server (table
cms_refresh_tokens, seeschemas/migrations/003-add-refresh-tokens.sqlin the skeleton application). Without the table logging in still works but/api/auth/jwt/revalidateanswers 401. The old refresh JWT cookie is not accepted any more, clients have to log in again once.POST /api/auth/jwt/revalidateno longer depends on a PHP session, only on the refresh cookie, and it rotates the token.POST /api/auth/jwt/identifyno longer depends on a PHP session either: it checks that the bearer token belongs to theuser_idin the body.New defaults: access token 900 seconds (was 3600), refresh token 7 days (was 3600), cookie path
/api/auth/(was/), cookie domain empty, meaning the host that set it (waslocalhost, which browsers reject on any real host),SameSite=Strict,Securewhen the request is HTTPS (was always on).The REST API now authorizes by role. Add the column
ALTER TABLE cms_users ADD COLUMN role varchar(20) NOT NULL DEFAULT 'user';and promote your administrators (UPDATE cms_users SET role = 'admin' WHERE ...), otherwise every privileged call answers 403 (the reason is written to the error log). Sections are admin only. Listing, creating and deleting users is admin only. A user can read and edit their own profile, but not their role.All
/api/sectionsroutes now require a bearer token.API user and section writes accept only whitelisted fields, other fields are rejected with 400.
user_type,group_id,ac_status,ac_key,ac_activeanduser_ipcannot be written through the API.The user API no longer returns the password hash or
ac_key.
Compatibility
Tested on MySQL 8.4, MariaDB 10.11 and MariaDB 11.4: the whole suite passes on all three, including the box lookup that uses
JSON_SEARCH(newBoxLookupTest), and CI runs MariaDB too. The skeleton's seeded schema loads on MariaDB 11.4.Tested with Nginx and PHP-FPM (the skeleton on Nginx 1.30, PHP 8.4 FPM and MariaDB 11.4): pages, trailing slash redirects, login, roles, refresh rotation and theft detection, the page cache with ETag, and the login rate limit all behave as on Apache. An Nginx configuration equivalent to the skeleton's
.htaccessis in the skeleton (etc/nginx/wbengine.conf).
Tests found real bugs
Automated tests for the API controllers, the session and the page cache decision
(463 tests now, green on PHP 8.3, 8.4 and 8.5) found and fixed:
The refresh endpoint started a PHP session after all (a
PHPSESSIDcookie and session queries on a stateless endpoint). It now builds the access token straight from the database row.WbengineRestapiAbstractcalledgetallheaders()directly, which does not exist outside Apache and FPM, so the API crashed there.Http::getHeader()is now case-insensitive (HTTP/2 and some proxies send lower case names) and falls back to$_SERVER.The
Bearerscheme was matched case-sensitively,bearer <token>was rejected (RFC 7235: schemes are case-insensitive).A request without a
User-Agentheader raised a warning that the error handler turned into a 500 (Utils::getUserAgent());Http::getCookie()did the same for a missing cookie.Apican be tested without ending the request or sending cookies:Api::setTerminator()andApi::setCookieWriter(), plusApi::getLastStatus().
Template engine
A native template engine,
Renderer\Adapter\NativeRenderer(Template\Engine,Template\Compiler,Template\ExpressionParser,Template\Runtime). Select it with"renderer": { "adapterName": "NativeRenderer" }; Smarty stays the default and supported. Templates are compiled once to plain PHP and included, so with opcache a render is just running compiled code.- Output is HTML-escaped by default (
{$value|raw}for trusted HTML). A template written with explicit|escapeand|rawworks in both engines.renderer.autoEscape: falsekeeps the Smarty behaviour for old templates. - A subset of the Smarty syntax:
{$a.b},{$o->p},{$o->m()}, modifiers,{if}/{elseif}/{else},{foreach}with key and{foreachelse},{include},{set},{literal}, comments. Lines break like Smarty after control tags. New:{cache 'key' ttl=60}...{/cache}fragment caching on top ofCache. Braces that do not start a tag (inline CSS and JavaScript) are plain text, no{literal}needed. - Safe by construction: expressions use a fixed grammar, so a template cannot call functions or run PHP; PHP tags in template text and template syntax in data are never executed; template names cannot leave the template directories; a missing variable, key or property is empty and never a warning.
- Verified: 43 templates render byte for byte the same as Smarty, all skeleton pages are identical with a settings change only, and a stored XSS probe is rendered live by Smarty and as harmless text by the native engine. Measured: about the same speed as Smarty for repeated renders (11 against 9 microseconds for the skeleton layout) and about 2 ms less overhead on a cold request (2.3 against 4.5 ms above a bare PHP process), because Smarty's classes are not loaded.
- Output is HTML-escaped by default (
The bundled templates (
Login.tpl,Article.tpl,error.tpl) now escape data explicitly and mark trusted HTML with|raw.Login.tplno longer writes a password back into the form.
Refresh tokens
A token family has an absolute lifetime (30 days by default,
Api::setRefreshAbsoluteLifetime()), however often it is used. This needs the columnfamily_started_at(schemas/migrations/004-refresh-token-family-start.sql); without it logging in works but no refresh token is issued.Server side refresh tokens (
Api\Auth\RefreshTokens): an opaque random value, only its SHA-256 hash is stored. Every refresh rotates the token. Presenting a token that was already used revokes its whole family (a stolen copy and the real owner are both logged out); a 10 second grace period covers two browser tabs refreshing at once, and exactly one of several simultaneous requests wins (tested with six parallel processes). Logout revokes the device, the newPOST /api/auth/logout/allrevokes every device of the user, a password change and deleting a user revoke all their tokens.POST /api/cache/flush(admin only) drops the cached data and pages after content was edited directly in the database. It runs inside the web server, so it reaches an APCu cache too, which a command line script could not.PageCache::sessionBelongsToAUser().Api::setCookieSameSite(),Api::getRefreshTokenTtl(),Db::forgetConnection()(for forked workers),Http::TOO_MANY_REQUESTS.
Rate limiting
Failed logins are limited for the web form, the AJAX login and
POST /api/auth/login(all go throughUser::login()): 5 failures per client IP and account together, 20 per IP over all accounts, 25 per account from all IPs, within 15 minutes. The API answers429with aRetry-Afterheader. The strict limit is per IP and account pair, so somebody failing on purpose cannot lock the real owner out. Counters are small files (no new table); configure or switch off withrateLimit.logininSettings.json. New:RateLimit\Limiter,User\TooManyAttemptsException,Config::getLoginRateLimit(),Http::TOO_MANY_REQUESTS.
Performance
New opt-in cache for the site table, the menu, the sections and the box lists (
Wbengine\Cache, APCu or file driver, enable with thecachesetting). On the skeleton application a warm page needs 1 to 3 queries instead of 8 to 10, and none for a visitor without a session cookie. The API flushes the cache when sections change, callCache::flush()after other direct edits.Sessions: the row is read once per request, created only when something is stored (anonymous visitors cost no
INSERT), written once at the end of the request instead of on everysetValue(), and expired sessions are purged on about 1 in 100 loads instead of on every request. A request without a session cookie skips the lookup entirely.Opt-in whole page cache for anonymous visitors (
cache.pages,PageCache). A hit is served from theApplicationconstructor before any box runs: no query for a visitor without a session cookie, one session lookup for a returning one,ETagand304 Not Modifiedsupport. A page is stored only for a plain GET of a site's own URL, for a visitor who is not logged in, with a 200 HTML response, when nothing was written to the session (a page with a login token is never stored) and the site needs no login. Verified: a logged-in visitor never reads or fills the cache and the admin's e-mail never appears in the anonymous copy. New:Cache::fetch(),Cache::store(),Session::wasModified(),Application::hasModifiedSession(). The API flushes pages together with the data cache.Opt-in batched view counting (
counters.views): the article view counter is written once per N views or per interval instead of on every request (newCounter\BatchCounter,Config::getViewCounterSettings()). Off by default, so views are written immediately as before.renderer.compileCheck: falseskips Smarty's per-request template check.
Přidáno
Continuous integration (
.github/workflows/ci.yml): on every push and pull request, PHP 8.3, 8.4 and 8.5 with MySQL 8.4 runcomposer validate --strict,composer audit,composer lintand the test suite. Dependabot watches the Composer packages and the workflow actions. New scripts:composer lint.Automated tests: PHPUnit 12 with unit and integration suites (63 tests) and a Docker test environment (
docker compose run --rm php composer test).Bound parameters:
Db::query(),fetchAssoc(),fetchObject(),fetchAllAssoc(),fetchRow(),fetchOne()andfetchAll()accept an array of parameters for?placeholders. NewDb::insert(),Db::update()andDb::identifier().User\Password:password_hash()based hashing. Legacy MD5 hashes still verify and are upgraded on the next successful login whencms_users.passwordcan hold a modern hash (varchar(255)recommended).Http::PostRaw()for secrets that must not be altered by HTML cleaning.Config::getJwtKeys()andConfig::getRecaptchaSecret().User\Role(user,editor,admin) andcms_users.role.WbengineRestapiAbstract::isAuthenticated($callable, $role)requires a role and hands the callable the token payload and the actor row. The role is read from the database on every privileged call, so a demotion or deletion takes effect at once and not when the token expires. The token also carries the role, for display only.Guards: nobody can delete their own account through the API, and the last administrator can be neither demoted nor deleted.
Cache(remember(),flush(),isEnabled(),setDriver()),Cache\FileCache,Cache\ApcuCache,Cache\NullCache,Config::getCacheSettings().Session::flush()writes pending session changes (called automatically at shutdown).Session::regenerateId(), called on login to prevent session fixation.Api\User::updateUser(), whichPATCH /api/users/patch/{id}had always routed to without it existing.Auth\Exception\AuthException::ERROR_MISSING_KEY.PageCache::setHeaderProvider()(the command line SAPI keeps no response headers, so tests supply them). The store rules of the page cache have automated tests, each one proven by mutation: disabling a rule makes exactly its test fail.
Odstraněno
The
Db\Adapter\Zendadapter (its dependency was never required and it did not implement the adapter interface), the unusedConfig\Adapter*classes,Box\Model, and the brokenConfig::autodetectEnvironment()with its helpers (addEnvironmentSafeKeyword(),addEnvironmentSafeIp(),DETECT_ENV_TYPE_*). They could not run: they used undefined constants and methods that do not exist.
Změněno
Session::save()no longer writes immediately, it marks the session for the write at the end of the request. A destroyed (logged out) session is never written again during that request.Rendereroptions: newrenderer.compileChecksetting. Set it tofalsein production so Smarty does not check template files on every request (clearView/Compiledon deploy). Defaults totrue.Session cookies are
HttpOnly,SameSite=Lax(andSecureover HTTPS) and strict mode is on. Stored session data is unserialized withallowed_classeslimited tostdClass.Query errors throw
DbExceptionwith a generic message. The SQL and the server error are written to the PHP error log instead of being returned to clients. mysqli reporting isERROR | STRICTon every PHP version.Exception pages show a generic 500 unless debug is enabled, and escape all output. The debug bar escapes the SQL it prints.
Router: a trailing slash is optional and the query string is ignored when matching routes.
An invalid, malformed or expired token is always a 401. Errors raised by the action itself are no longer reported as authentication failures.
POST /api/sectionsreturns 201.Http::CONFLICTis 409.The error handler no longer throws for deprecations and notices, and honors the
@operator.usernamedefaults to the e-mail when a user is created through the API.PageCachesets itsX-WBengine-Cacheheader only when headers can still be sent.UrlandHttp::getRequestMethod()read$_SERVERinstead offilter_input(INPUT_SERVER, ...), which is not available in every SAPI and configuration (it always returns null on the command line). Behaviour is unchanged where it worked; it is now also testable.
Opraveno
Deleting a user through the API reported
success: falsealthough the user was deleted (the token revocation ran before the affected row count was read).PHP 8.5 deprecations: the
(boolean)cast spelling and acase x;with a semicolon. The suite and the linter are clean on PHP 8.3, 8.4 and 8.5.Url\Exception\RunetimeException.phpandFormater\Exception\RunetimeException.phphad a typo in the file name, so the classes could not be autoloaded on case sensitive file systems or with an authoritative classmap.Renderer\Adapterwrappers (setCacheDir,setLeftDelimiter,setRightDelimiter,registerObject) called a method with a missing argument.SmartyRendererused a Smarty call that no longer exists and ignoredsetConfigDir.PHP 8.3 and 8.4: implicit nullable parameters, an optional parameter before a required one (
Application::setPath), dynamic properties inSection,Site,Url,Auth,Mysqliand the session singleton,sizeof(null), removed magic quotes helpers inUrl, array access on null ingetModel()andUser, theend(explode())notice.microtime()was used withouttrue, which made the debug timings wrong. The summed query time only counted the last query.Session: the singleton was assigned to an instance property and checked against the wrong class.Passwords were altered by
htmlspecialcharsandaddslashesbefore hashing.Site lookups, title lookup, menu, box existence and site type queries built SQL from request data. They use bound parameters now. Two of them called methods that did not exist.
API routes without a trailing slash (all
/api/usersroutes) were unreachable behind a redirecting.htaccess.
Zabezpečení
SQL injection through the URL path and the
User-Agentheader (no login needed) and through API request bodies.The JWT keys and the reCAPTCHA secret were hardcoded in the source. Treat the previous values as compromised.
firebase/php-jwt5.x is affected by published security advisories.Unauthenticated create, update and delete of sections.
User creation and update could set privileged columns.
Debug and exception output could inject HTML and exposed SQL and file paths.
Poznámky k upgradu
- Add the
jwtsection (andrecaptchaif you use the login captcha) toSettings.json.
- Add the
- Run
ALTER TABLE cms_users MODIFY password varchar(255) NOT NULL DEFAULT '0';
- Run
- Move string built SQL to bound parameters.
- If your
.htaccessredirects URLs to add a trailing slash, exclude/api.
- If your
- Rotate any credential that was ever committed to a repository.
v3.0.0 2025-08-27
Poznámky
Initial documented release, see the git history.