🚀 Changelog — Flatboard 5.5.1 "AEGIS"
Release date: April 18, 2026
Fixed
- Core — Login redirect broken in subdirectory installs — After login (including 2FA), the redirect target stored in
redirect_after_loginis a base-path-stripped URL (as set byAuthMiddleware).LoginControllernow wraps it withUrlHelper::to()before redirecting, restoring the subdirectory prefix.Response::back()andController::back()likewise now useUrlHelper::to('/')as their fallback instead of the bare"/"string. Files changed:app/Controllers/Auth/LoginController.php,app/Core/Response.php,app/Core/Controller.php. - Core —
remember_tokencookie path hardcoded to/in subdirectory installs —Session::setRememberToken()andSession::clearRememberToken()now derive the cookiepathfromUrlHelper::getBaseUrl(), producing/subfolder/for subdirectory installs and/for root installs. Files changed:app/Core/Session.php.
Security
- Core — Open redirect via
HTTP_REFERER—Response::back()now validates that theRefererhost matches the configuredsite_urlbefore redirecting; falls back to the$defaultparameter otherwise. Files changed:app/Core/Response.php. - Core — Header injection in
Content-Disposition—AttachmentController::serveFile()now strips CR, LF, and double-quote characters from filenames before injecting them into theContent-Dispositionheader, and adds a properly RFC 6266-encodedfilename*parameter for Unicode filenames. Files changed:app/Controllers/AttachmentController.php. - Core — CSRF protection on
POST /search—SearchController::index()now callsverifyCsrf()when handling a POST request. Files changed:app/Controllers/Discussion/SearchController.php. - Plugin PrivateMessaging — CSRF on all write API endpoints —
verifyCsrf()added tosend(),toggleStar(),markRead(),delete(),deleteMultiple(), anddeleteThread(). Files changed:plugins/PrivateMessaging/PrivateMessagingController.php. - Plugin FlatHome — CSRF on all admin POST endpoints —
verifyCsrf()added to all 9 write actions:adminStore,adminUpdate,adminDelete,adminReorder,adminNavReorder,adminNavSave,adminQuickSettings,adminGroupStore,adminGroupUpdate,adminGroupDelete. Views were already sending the token. Files changed:plugins/FlatHome/FlatHomePageController.php. - Core —
HTTP_USER_AGENT/HTTP_REFERERtruncated before persistence —Request::getUserAgent()now caps at 512 characters.LogMiddlewareandVisitorTrackingMiddlewarenow capHTTP_REFERERat 2 048 characters before logging or passing to analytics. Files changed:app/Core/Request.php,app/Middleware/LogMiddleware.php,app/Middleware/VisitorTrackingMiddleware.php. - Core — Inline
<style>blocks now pass throughInlineAssetHelper::style()— Four view files that were emitting raw<style>tags are now compliant with the minification convention. Files changed:themes/premium/views/components/theme-colors.php,app/Views/users/settings.php,app/Views/users/list.php,app/Views/users/profile.php.
Added
- Core — Homepage view option — A new
homepage_viewsetting (Admin → Settings → General) lets administrators choose what the forum homepage (/) displays by default: latest discussions (existing behaviour) or the forum categories grid. The URL/forumsalways shows the categories grid; the URL/discussionsalways shows the full discussion list — both are permanent, regardless of the homepage setting. Navigation links ("Forums" / "All discussions") are fixed and always match their destination. All three themes (premium, ClassicForum, IPB) updated. Files changed:app/Core/Config.php,app/Core/App.php,app/Controllers/Admin/ConfigController.php,app/Controllers/Discussion/DiscussionController.php,app/Views/admin/config.php,app/Views/discussions/index.php,app/Views/categories/index.php,themes/premium/views/{discussions,categories}/index.php,themes/ClassicForum/views/{discussions,categories}/index.php,themes/IPB/views/{discussions,categories}/index.php,languages/{fr,en,de,pt,zh}/admin.json.
Fixed
- Core — FlatLetter newsletter widget: typing in email field threw
TypeError: this.checkValidity is not a function—initFormEnhancements()inux-enhancements.jsattached ablurvalidation listener to every.form-controlelement, including the<div class="nl-fake-field form-control">used by FlatLetter as a click-to-activate placeholder. When that div lost focus, the listener calledthis.checkValidity(), which doesn't exist on non-form elements. Fixed by skipping elements that don't implementcheckValidity(). Files changed:themes/premium/assets/js/ux-enhancements.js. - Core — API: autocomplete search never showed results —
search-form-manager.jsreadresponse.resultsbutSearchApiControllerreturnedresponse.data. Autocomplete results were always silently discarded. Fixed by addingresultsas an explicit alias in the response (alongsidedatakept for backward compatibility). Pagination fields (total,count,offset,limit,hasMore) also promoted to root level of all relevant API responses:PostApiController,DiscussionApiController,UserApiController(discussions/posts/reactions/subscriptions),TagApiController::getDiscussions,SearchControllerAJAX. Files changed:app/Controllers/Api/SearchApiController.php,app/Controllers/Api/PostApiController.php,app/Controllers/Api/DiscussionApiController.php,app/Controllers/Api/UserApiController.php,app/Controllers/Api/TagApiController.php,app/Controllers/Discussion/SearchController.php. - Core — Load-more pagination: last partial page incorrectly hid the "Load more" button —
load-more-manager.jsevaluatedhasMoreasdata.hasMore !== false && items.length === this.perPage. When an API returned an explicitdata.hasMore = truewith a partial page (fewer items thanperPage, e.g. due to permission filtering), thelengthcondition overrode the API value and the button disappeared. Fixed by trustingdata.hasMorewhen explicitly present and usingitems.length >= perPageonly as a fallback. Files changed:themes/assets/js/frontend/modules/load-more-manager.js. - Core — Profile page load-more: potential infinite loop when API returns count 0 —
profile-manager.jsupdated the button's offset withdata.count || 0: ifdata.countwas 0 anddata.hasMorewas stilltrue, the offset never advanced and each click re-fetched the same (empty) page indefinitely. Fixed by removing the button wheneverloadedCount === 0, regardless ofhasMore. Files changed:themes/assets/js/frontend/modules/profile-manager.js. - Core — AJAX search dropdown: "View all results" always showed (20) — Three separate issues: (1)
SearchApiControllercomputed$total = count($allResults)with$searchLimit = $limit(=20), sototalwas always capped at 20; fixed by callingcountSearch()for the total andsearch($offset+$limit)only for the page; (2)SearchService::searchPaginated()similarly capped the total atMAX_COUNT_FOR_TOTAL = 200; replaced with a dedicatedcountSearch()method that scans up to 1,000 items per type and returns the real count —searchPaginated()uses it with a separate cache key; (3)search.jsreaddata.totalResultswhichSearchApiControllernever returned (onlydata.total); fixed by adding atotalResultsalias inSearchApiControllerand adding adata.totalfallback in the JS. AdditionallyformatUserResult()now processes the user bio throughextractExcerpt()(markdown → plain text) so raw**asterisks**no longer appear in excerpts. Files changed:app/Services/SearchService.php,app/Controllers/Api/SearchApiController.php,themes/assets/js/search.js. - Core — Search results page: "Load more" button loaded nothing —
SearchControllerAJAX response did not include ahtmlfield;load-more-manager.jscalledappendItems(items, data.html)butdata.htmlwasundefinedso nothing was appended, and norenderItemfallback was configured. Fixed by: (1) generating HTML inSearchController's AJAX branch usingSearchResultFormatter(before sanitizing excerpts, so<mark>highlights are preserved); (2) rewritingSearchResultFormatter::formatItem()to produce the same Bootstrap card structure assearch.php(previously it usedlist-group-item, causing visual inconsistency on load-more). Files changed:app/Controllers/Discussion/SearchController.php,app/Services/SearchResultFormatter.php. - Core — System emails now sent in the site language —
sendVerification(),sendPasswordReset(), andsendEmailChangeConfirmation()inEmailServicehad their full HTML bodies hardcoded in French, ignoring thelanguage/default_languageconfig setting and the existinglanguages/*/emails.jsontranslation files. A new privateemailTrans()helper reads the correct language file directly (bypassing the session-user language preference, since these are site-level notifications). All five language files (fr,en,de,pt,zh) have been extended with the missing keys (verification.*,passwordReset.*,emailChange.*,common.greeting,common.orCopy,common.autoEmail). The shared HTML template was extracted intobuildEmailHtml()to avoid duplication. Files changed:app/Services/EmailService.php,languages/{fr,en,de,pt,zh}/emails.json. - Core — Local update no longer duplicates indexed arrays of objects in plugin.json —
deepMergePreserveExisting()inUpdateControllerusedarray_unique(array_merge(...), SORT_REGULAR)for all indexed arrays. This works correctly for scalar arrays (hook names, etc.) but caused duplication for indexed arrays of objects (e.g. FlatHomepage_groups): because the archive copy and the user's customised copy differ,SORT_REGULARdid not recognise them as equal, so both survived the merge and the group was doubled on each update. Fixed by splitting the merge strategy: scalar indexed arrays still use the union/dedup approach; indexed arrays of objects use a newmergeIndexedObjectArrayById()helper that merges by theidfield — existing entries are preserved, and only genuinely new entries from the archive are appended. Files changed:app/Controllers/Admin/UpdateController.php. - Core — Admin users: delete-permanently confirmation modal now respects site language — Three hardcoded French strings remained in the modal: (1) title "Supprimer définitivement" —
users-management.jslooked forusers.title.delete_completebutusers.titleis a string, not an object; fixed by addingusers.modal_title.delete_completeto all 5 admin language files and updating the lookup; (2) "Annuler"/"Confirmer" buttons —UIHelpers.jslooked forwindow.Translations.common.button.*but translations are domain-prefixed (main.common.button.*); (3)ConfirmModal.show()fallback defaults were hardcoded French strings. Files changed:themes/assets/js/admin/modules/users-management.js,themes/assets/js/admin/components/UIHelpers.js,themes/assets/js/admin/components/confirm-modal.js,languages/{fr,en,de,pt,zh}/admin.json. - Pro — FlatHome blog: date format setting now correctly applied everywhere — Two separate issues prevented the "Date Format" setting (Admin → FlatHome) from taking effect. (1)
PluginSettingsController::convertFormConfigKeyToPluginKey()strips thedate_prefix from any field key, so the form fielddate_formatwas saved under keyformatwhile both blog views readdate_format— always falling back torelative. Fixed by readingformatfirst withdate_formatas fallback; the orphaneddate_formatdefault removed fromplugin.json. (2) The "Recent posts" sidebar widget inarticle.phpcalledDateHelper::relative()directly instead of theflathome_date_art()helper, hardcoding the relative format. Files changed:plugins/FlatHome/views/blog/index.php,plugins/FlatHome/views/blog/article.php,plugins/FlatHome/plugin.json. Plugin:FlatHome 1.0.8.
🚀 Changelog — Flatboard 5.5.0
Release date: April 13, 2026
Performance
- Pro — FlatHome:
getAllPages()cached for the duration of the request — Called on every page load viabuildNavList()(view.navbar.itemshook), this method ran aglob()+ one file read per CMS page on each request. Static variables now cache the result for the lifetime of the request. Files changed:plugins/FlatHome/FlatHomeService.php. Plugin:FlatHome 1.0.6. - Pro — FlatModerationExtend: storage reads cached within each request —
getPendingPosts(),getPendingDiscussions(), andgetShadowBans()each performed aglob()+ N file reads per call with no caching. Added class-level static caches invalidated automatically after any write. Files changed:plugins/FlatModerationExtend/FlatModerationExtendStorage.php. Plugin:FlatModerationExtend 1.0.2. - Core —
PermissionHelper::can()now cached for the duration of the request — Every call previously executedUser::find()+GroupHelper::isAdmin()+StorageFactory + getGroupPermissions()unconditionally. On a standard page load, theview.navbar.itemshook alone triggered ~12 uncached I/O reads just to check two permissions for Private Messaging. Results are now stored in a static$cachearray keyed byuserId:permissionKey; identical lookups within the same request are answered instantly. AclearCache()method is available for the rare case where permissions change mid-request. Files changed:app/Helpers/PermissionHelper.php. - Pro — Forum Monitoring: fatal memory exhaustion fixed + widget stats cached —
getAllUsers(),getAllDiscussions(), andgetPostsByDiscussion()helpers were calling themselves recursively (infinite loop), and static caches were keeping up to 10 000 full records in memory for the entire request, exhausting the 256 MB PHP limit. Both fixed: helpers now correctly callUser::all()/Discussion::all()/Post::byDiscussion(), and all 6 stat calls are wrapped inCache::getOrSet()(5-min TTL) so the heavy computation runs at most once every 5 minutes.clearRequestCache()frees bulk data immediately after caching. Files changed:plugins/ForumMonitoring/ForumMonitoringService.php,plugins/ForumMonitoring/ForumMonitoringPlugin.php. Plugin:Forum Monitoring 1.1.4. - Pro — Forum Monitoring: dashboard widget no longer re-loads the entire forum on each stat call —
addDashboardWidget()called 6 stat methods; each independently calledUser::all(10000),Discussion::all(10000), andPost::byDiscussion()in a loop for every discussion. With 50 discussions and 6 methods this produced 300+ redundant file reads per dashboard load, contributing ~500 ms of latency. Three private static helpers (getAllUsers,getAllDiscussions,getPostsByDiscussion) now share a single load across all method calls for the lifetime of the request. Files changed:plugins/ForumMonitoring/ForumMonitoringService.php. Plugin:Forum Monitoring 1.1.3.
Fixed
- Admin — Users: raw translation key shown after anonymize / full delete —
UserManagementController::anonymize()anddeleteComplete()calledTranslator::trans('admin.users.message.anonymized', [], 'admin')andTranslator::trans('admin.users.message.deleted_complete', [], 'admin'). The domain is already'admin', so the Translator looked for a non-existentadminsub-key insideadmin.jsonand returned the raw key string as the toast message. Removed the erroneousadmin.prefix. Files changed:app/Controllers/Admin/UserManagementController.php. - Core — Purge unverified accounts: admin button + CLI command — Accounts with
email_verified = falseandcreated_atolder than N days (default 7, the token TTL) can now be purged via the Maintenance panel in the admin dashboard or viaphp console.php cleanup:unverified-users [days]. ImplementedpurgeUnverifiedUsers(int $days)inStorageInterface,JsonStorage, andSqliteStorage. The action is logged to the security log. Files changed:app/Storage/StorageInterface.php,app/Storage/JsonStorage.php,app/Storage/SqliteStorage.php,app/Controllers/Admin/MaintenanceController.php,app/Core/App.php,app/Views/admin/dashboard.php,themes/assets/js/admin/modules/dashboard-management.js,app/Cli/Commands/CleanupCommand.php,app/Cli/console.php, all 5languages/*/admin.json. - Admin — Users: email-verified filter now appears when email verification is enabled —
admin/users.phpwas readingemail.verification(non-existent key) instead ofemail_verification, so$emailVerificationConfiguredwas alwaysfalseand the filter dropdown was never rendered. Removed the redundant variable and reuse$emailVerificationEnableddirectly. Files changed:app/Views/admin/users.php. - Members list: unverified users no longer appear on the public
/userspage —UserController::index()andUserController::search()loaded all users viaUser::all()without filtering out accounts withemail_verified = false. When email verification is enabled, unverified accounts are now excluded from both the initial members list and the AJAX search results, consistent with the behaviour already applied to stats and theme member counts. Files changed:app/Controllers/User/UserController.php. - Themes — Stats: unverified members no longer appear in "latest member" and total user counts —
latestMembers,total_users, andactive_membersnow filter out accounts withemail_verified = falseacross all themes (premium,IPB,ClassicForum).AnalyticsService::getStats()applies the same filter so the admin dashboard count is also accurate. Files changed:themes/premium/views/discussions/index.php,themes/premium/views/categories/index.php,themes/IPB/views/discussions/index.php,themes/IPB/views/categories/index.php,themes/ClassicForum/views/discussions/index.php,themes/ClassicForum/views/categories/index.php,app/Services/AnalyticsService.php. - Community — EasyMDE: draft auto-save always sent empty content —
getEditorValue()hook inEasyMDEPlugin.phpwas missing areturnstatement. The hook script assigned to an implicit variable instead of returning a value, so the JS function inmarkdown-editor.phpreturnedundefinedon every auto-save cycle; the server stored null content. Fixed by returningeditor.value()directly (same pattern already applied in TuiEditor v10). Files changed:plugins/EasyMDE/EasyMDEPlugin.php. Plugin:EasyMDE 2.3.4. - Pro — Private Messaging: typing indicator never rendered — Three bugs prevented the typing indicator from working at all in the compose view. (1) Variables passed to the typing-indicator component used wrong names (
$typingContextetc. instead of$contextetc.), causing the component to exit immediately on empty$contextId. (2) The context value'pm'is not in the server'sALLOWED_CONTEXTS; changed to'message'. (3) The JS typing event was sent only once per session; after 5 seconds of continuous typing the server cache expired and the indicator disappeared for the recipient — the event is now refreshed every 3 seconds while typing is active. Files changed:plugins/PrivateMessaging/views/compose.php,themes/assets/js/typing-indicator.js. Plugin:Private Messaging 1.1.3. - Core — Double toast on incompatible plugin toggle — Toggling an incompatible plugin displayed two identical error toasts on a single click. The global
fetchinterceptor intoast.jsautomatically shows a toast for every 4xx/5xx response, butplugins-management.jsalso shows one in its owncatchblock. Fixed by adding/admin/plugins/toggleand/admin/plugins/uninstallto themanualErrorRouteslist so the interceptor skips them and lets the module handle its own error display. Files changed:themes/assets/js/shared/toast.js.
Added
- Admin — Users: filter by group — A "Group" dropdown has been added to the users list filter bar. Selecting a group shows only members of that group; the filter is preserved across sort and pagination links. The reset button clears the group filter along with the existing filters. Files changed:
app/Controllers/Admin/UserManagementController.php,app/Views/admin/users.php. - Theme Premium — Full stats block now shown in all sidebar views — A new shared component
sidebar-stats.phprenders the complete stats block (discussions, replies, members, active members, latest member, online users, guests online) in the sidebar of every view that has a column:categories/index.php,discussions/index.php(including when a category is selected),discussions/tags.php, anddiscussions/show.php. Two new theme settings added:show_sidebar_stats(show/hide the entire block) andshow_sidebar_guests(show/hide the guest count line). Both settings are configurable in the theme admin panel. Translations added to all 5 theme language files. The oldshow_stats/stats_display_modeinline rendering has been removed from the individual views. — The sidebar column indiscussions/index.php(including when browsing a specific category),categories/index.php, andtags.phpnow all display an online block showing member avatars and the guest count. Indiscussions/index.phpthe block appears as a standalone card whenever the full compact stats block is not shown (category selected, stats disabled, or non-compact mode). Intags.phpthe block is always present in the sidebar whenshow_user_presenceis enabled. Guest count fetched viaPresenceService::getActiveAnonymousVisitorsDetailed(). New translation keycommon.stats.online_guestsadded to all 5 language files. Files changed:themes/premium/views/categories/index.php,themes/premium/views/discussions/index.php,themes/premium/views/discussions/tags.php,languages/{fr,en,de,pt,zh}/main.json.
Improved
- Theme Premium — Sidebar stats block redesigned — The shared
sidebar-stats.phpcomponent now has a card header with a chart-bar icon and translated title, a two-column Bootstrap grid for the four counters (number prominent, label muted below), a compact "New member: [link]" single-line row, and a unified "Online" section introduced by a green dot indicator. The guest count is displayed inline with afa-user-secreticon. Files changed:themes/premium/views/components/sidebar-stats.php,languages/{fr,en,de,pt,zh}/main.json. - Core — Search: excerpts no longer show raw markdown or bleed inline formatting —
SearchService::extractExcerpt()now parses the markdown to HTML, strips<pre>code blocks (noise in excerpts), then reduces the result to plain text viastrip_tags()+html_entity_decode()before truncating. This prevents raw markdown syntax (](url),`) and malformed<strong>/<em>tags in storedrendered_htmlfrom bleeding bold or italic across the entire excerpt. Files changed:app/Services/SearchService.php. - Core — Search: excerpts use the first post's cached
rendered_html—formatDiscussionResult()now callsPost::getFirstPost()(which has its own request-scoped cache) to retrieve the pre-rendered HTML instead of re-parsing the discussion's raw markdown on every result. Falls back toMarkdownHelper::parse()for discussions without a cached first post. Files changed:app/Services/SearchService.php. - Core — Search: sort → slice → format (was: format all matches, then sort) —
performSearch()now collects raw scored items from all sources, sorts and slices to the page limit, and only then loads related data (categories, users, parent discussions) and formats the final set. ReducesMarkdownHelper::parse()calls and storage lookups from the total number of matches to at most the page size (default 20). Files changed:app/Services/SearchService.php. - Core — Search: result URLs always include the slug —
formatDiscussionResult()andformatPostResult()used??to fall back toSanitizer::slug($title), which does not bypass empty strings. Replaced with?:so a stored empty-string slug correctly falls back to a slug generated from the title. Files changed:app/Services/SearchService.php. - Core — Search: result cards are fully clickable — Added Bootstrap
stretched-linkto the title<a>so clicking anywhere on a result card navigates to the discussion or post. Badges, the author link, and the excerpt container are set toposition: relative; z-index: 1so they remain independently clickable and escape the stretched-link overlay. Files changed:themes/premium/views/discussions/search.php,app/Views/discussions/search.php. - Core — Search view (default theme):
<style>block passes throughInlineAssetHelper— The raw<style>block inapp/Views/discussions/search.phpwas not going throughInlineAssetHelper::style(), violating the project convention for all inline styles. Files changed:app/Views/discussions/search.php.
🚀 Changelog — Flatboard 5.4.8
Release date: April 9, 2026
Fixed
- Pro — FlatHome: PHP warning
Undefined variable $blogCatNameon pages list —FlatHomePageController::adminPages()did not pass$blogCatNameto thepages.phpview, yet the view used it unconditionally. The variable is now computed at the top of the view from the already-available$settingsarray (same pattern asadmin.php): looks up the blog category bysettings.blog_categoryand falls back to an empty string when unset. Files changed:plugins/FlatHome/views/admin/pages.php. Plugin:FlatHome 1.0.5.
🚀 Changelog — Flatboard 5.4.7
Release date: April 9, 2026
Fixed
- Incompatible plugin activation blocked —
PluginController::toggle()allowed activating a plugin even when its version constraints were not satisfied: clicking the toggle after an auto-disable showed "Extension modified successfully" and re-enabled the plugin. The activation branch now callsPluginHelper::checkDependencies()before writingactive = 1; if the check fails, a JSON error is returned with a new keyplugins.cannot_activate_incompatible(added in all 5 languages) so the admin sees an explicit message explaining that the plugin must be made compatible before it can be activated. Files changed:app/Controllers/Admin/PluginController.php,languages/{fr,en,de,pt,zh}/admin.json. - Incompatible plugin — notification bell duplicated on every page visit —
Plugin::disableIfIncompatible()re-set theauto_disabled_incompatibleflag on every boot whenever the flag was absent and the plugin was incompatible — even if the plugin was alreadyactive = 0. BecausePluginController::notifyAndClearIncompatibleFlags()always clears the flag after sending the notification, this created a loop: flag cleared → next request re-sets flag → next page visit sends another notification. Fixed by returning early indisableIfIncompatible()when the plugin is already inactive, so the flag is only ever set when a plugin transitions from active to disabled for the first time. Files changed:app/Core/Plugin.php.
🚀 Changelog — Flatboard 5.4.6
Release date: April 8, 2026
Added
- Auto-disable incompatible plugins — Plugins whose
requires.flatboardversion constraint is not satisfied by the running Flatboard version are now automatically disabled at boot time (Plugin::disableIfIncompatible()). Theactivefield is set to0inplugin.jsonand a one-shot flagauto_disabled_incompatibleis written. On the next visit toAdmin → Plugins,PluginControllerreads the flag, sends a system bell notification to every admin user (no duplicates), and clears the flag. Files changed:app/Core/Plugin.php,app/Controllers/Admin/PluginController.php,languages/{fr,en,de,pt,zh}/admin.json. - Incompatible filter + UX improvements in plugin list — A red Incompatible filter button appears when at least one incompatible plugin exists, with a count badge. The plugin count is now shown on every filter button. A dismissible banner appears at the top of the page summarising how many plugins were auto-disabled. Incompatible plugin cards use a red border and a
fa-plug-circle-exclamationicon, and the layout switches from 3 to 4 stat columns when incompatible plugins exist. Files changed:app/Views/admin/plugins.php,languages/{fr,en,de,pt,zh}/admin.json. - Theme compatibility check —
ThemeController::getThemes()now evaluatesrequires.flatboardintheme.json. Incompatible themes display a red badge in their header and an alert block listing missing requirements; the activate button is replaced by a disabled ban icon. A warning banner appears at the top ofAdmin → Themeswhen any incompatible themes are detected. Themes are not auto-deactivated (no guaranteed fallback). Files changed:app/Controllers/Admin/ThemeController.php,app/Views/admin/themes.php,app/Views/admin/components/ThemeCard.php,languages/{fr,en,de,pt,zh}/admin.json. - Plugin compatibility warnings — The admin plugin list (
Admin → Plugins) now detects plugins whoserequires.flatboard(orrequires.php/requires.extensions/requires.plugins) is not satisfied by the current environment. Incompatible plugins display a red Incompatible badge in their card header (tooltip lists the missing requirements) and an alert block inside the card body. The check is performed byPluginHelper::checkDependencies(), which previously handled PHP and extension constraints but never checked theflatboardversion key. No plugins are automatically disabled — the warning is informational. Files changed:app/Core/PluginHelper.php,app/Controllers/Admin/PluginController.php,app/Views/admin/plugins.php,languages/{fr,en,de,pt,zh}/admin.json. - Hook
plugin.settings.form_config— New hook fired byPluginSettingsController::index()just before rendering the standard settings page (/admin/plugins/settings?plugin=…). Passes$formConfigby reference and$pluginIdas a second argument, allowing plugins to inject dynamic options (e.g. group lists) intoselectfields defined in theirform_config. Used by InactiveUserManager to populate the Protected group select. Files changed:app/Controllers/Admin/PluginSettingsController.php,plugins/InactiveUserManager/InactiveUserManagerPlugin.php,docs/8-plugins.md.
Fixed
- Pro — ForumImporter: N+1 slug uniqueness —
BaseImporter::uniqueSlug()ran aSELECT COUNT(*)in awhileloop for every slug to verify. All existing values are now loaded in a singleSELECTon first call per table/field; subsequent uniqueness checks and registrations operate exclusively in memory for the duration of the step. Eliminates thousands of queries on large imports. Files changed:plugins/ForumImporter/Importers/BaseImporter.php. - Pro — ForumImporter: N+1 duplicate user check —
upsertUser()ranSELECT id FROM users WHERE email = ? OR username = ?for each imported user. Replaced with lazy pre-loading of all existing users into two in-memory indexes (email → id, username → id) on first call; subsequent lookups cost zero queries. Indexes are updated in real time after each insertion. Files changed:plugins/ForumImporter/Importers/BaseImporter.php. - Pro — ForumImporter: unpaginated users (memory risk) — The
importUsersstep of each importer loaded all users into RAM in a singlefetchAll()query. All importers are now paginated at 500 users per batch (same mechanism as discussions/posts). The controller recognisesdone=falseand automatically replays the step. Files changed:plugins/ForumImporter/Importers/BaseImporter.php,plugins/ForumImporter/ForumImporterController.php, all importers. - Pro — ForumImporter: incorrect
is_first_postduring finalization — TheSELECT MIN(id)query on UUID v4 (random) values produced a lexicographic result unrelated to chronological order. Replaced withMIN(rowid)(SQLite auto-incremented integer, reflecting actual insertion order). Finalization first resets allis_first_postto 0, then marks only the lowest rowid per discussion. Files changed:plugins/ForumImporter/ForumImporterController.php. - Pro — ForumImporter: GROUP BY MIN() for
is_first_poststored in session (phpBB, MyBB) — phpBB and MyBB loaded allMIN(post_id) GROUP BY topic_idinto the PHP session to determine the first post of each topic. Replaced with aLEFT JOINonphpbb_topics.topic_first_post_id/mybb_threads.firstpostdirectly in the posts query, eliminating the full-scan query and session storage. Files changed:plugins/ForumImporter/Importers/Forums/PhpbbImporter.php,plugins/ForumImporter/Importers/Forums/MybbImporter.php. - Pro — ForumImporter:
last_post_atupdated post by post — Each inserted post immediately triggered anUPDATE discussions SET last_post_at. Replaced with collecting the max timestamp per discussion during the loop, then a single UPDATE per touched discussion at the end of the batch (reduces UPDATEs by ~80% on large imports). Files changed: all importers. - Pro — ForumImporter: Flarum —
group_userloaded globally — Thegroup_usertable was fully loaded into memory before processing users, then stored in the session. With user pagination, it is now loaded per batch viaWHERE user_id IN (...), without exhausting the session. Files changed:plugins/ForumImporter/Importers/Forums/FlarumImporter.php. - Pro — ForumImporter: Discourse — unquoted reserved word
primary— The joinuser_emails e ON ... AND e.primary = trueusedprimarywithout quotes, which is syntactically invalid in PostgreSQL (primaryis a reserved word). Fixed ase."primary". The query was rewritten to joinuser_profilesin the same statement (bio + website), removing two separate global SELECTs. Assignment to non-automatic groups viagroup_usersis now implemented. Files changed:plugins/ForumImporter/Importers/Forums/DiscourseImporter.php. - Pro — ForumImporter: vBulletin —
@unserialize()without validation —@unserialize($row['data'])silently suppressed errors and potentially allowed instantiation of arbitrary objects. Replaced withunserialize($row['data'], ['allowed_classes' => false])(PHP 7.0+), which disables object instantiation and lets errors surface normally. Files changed:plugins/ForumImporter/Importers/Forums/VbulletinImporter.php. - Pro — ForumImporter: phpBB —
user_sigmapped to wrong field — The phpBB signature (user_sig, displayed below posts) was passed to thebioparameter ofupsertUser()instead ofsignature. Fixed:user_sig→ Flatboardsignaturefield;bioremainsnull(phpBB 3.3 has no native standard biography field). Files changed:plugins/ForumImporter/Importers/Forums/PhpbbImporter.php. - Pro — ForumImporter: missing SQLite performance PRAGMAs — The target SQLite connection was missing
PRAGMA synchronous = NORMAL(3–5× faster thanFULLin WAL mode),PRAGMA cache_size = -32000(32 MB page cache), andPRAGMA temp_store = MEMORY. Added togetSqliteDb(). Files changed:plugins/ForumImporter/ForumImporterController.php. Plugin:ForumImporter 1.1.0. - Pro — FlatHome: "Users" navbar link now respects group permissions — The "Users" link injected by FlatHome via
view.navbar.itemswas always visible regardless of group permissions. FlatHome also suppresses the theme's native link via CSS (data-fhselector), making it the sole source of this entry. The link is now hidden when the group has neitherprofile.viewnorpresence.view. The same guard was also added to the native navbar of all three themes (premium,ClassicForum,bootswatch) as a fallback when FlatHome is inactive. Files changed:plugins/FlatHome/FlatHomePlugin.php,themes/premium/views/layouts/frontend/header.php,themes/ClassicForum/views/layouts/frontend/header.php,themes/bootswatch/views/layouts/frontend/header.php. Plugin:FlatHome 1.0.4.
🚀 Changelog — Flatboard 5.4.5
Release date: April 7, 2026
Fixed
- Shortcodes:
913caused a slowtheme.setting.valuehook (457 ms) — Thestat_postsshortcode callback used the deprecatedPost::all()to count posts, loading every post file on a cache miss. On JSON storage this now sums the denormalizedpost_countfield from the discussions cache (already in memory — no post files read); on SQLite it runsSELECT COUNT(*) FROM posts. AddedcountAllPosts()toStorageInterface,JsonStorage, andSqliteStorage. Thestat_discussionsshortcode also lacked a per-request cache and calledDiscussion::count()on every invocation; astatic $cachedguard is now in place. Files changed:app/Storage/StorageInterface.php,app/Storage/JsonStorage.php,app/Storage/SqliteStorage.php,plugins/Shortcodes/ShortcodesRegistry.php. Plugin:Shortcodes 1.3.3.
🚀 Changelog — Flatboard 5.4.4
Release date: April 6, 2026
Fixed
- Upgrade: EasyMDE plugin configuration erased on update —
deployUpdateFiles()usedAtomicFileHelper::readAtomic()to readplugin.jsonfiles before merging them. If the file lock could not be acquired within the 2-second timeout (e.g. under PHP-FPM load), the method returnednull, the!is_array()guard was triggered, and a rawcopy()was performed — silently overwriting the installed plugin's configuration with the archive defaults. Replaced with directfile_get_contents()+json_decode()reads (safe during updates since maintenance mode is active) and an atomicfile_put_contents(tmp) + rename()write, eliminating the lock-timeout failure path. Reported by [arpinux](Flatboard 5.4.1 — Release). Files changed:app/Controllers/Admin/UpdateController.php. - French UI: "Télécharger une sauvegarde" button mislabelled — The backup upload button and its related strings used télécharger (download) instead of téléverser (upload) throughout the French admin interface. Corrected across all related keys: button label, modal title, submit action, progress message, success/error toasts, and the update use-case description. Reported by [arpinux](Flatboard 5.4.1 — Release). Files changed:
languages/fr/admin.json. - Pro — StorageMigrator: maintenance mode not activated during migration — The storage migration ran while the forum remained fully accessible to visitors, risking data inconsistency during the switch. The controller now enables maintenance mode at the start of the first step, memorises its prior state, and restores it to its original value once
finalizingcompletes — whether via full migration or quick switch — and also on any error. If maintenance mode was already active before the migration started, it is left active after. Files changed:plugins/StorageMigrator/StorageMigratorController.php. Plugin:StorageMigrator 1.1.3.
🚀 Changelog — Flatboard 5.4.3
Release date: April 4, 2026
Fixed
- Pro — FlatSEO: sitemap and breadcrumb replaced EasyPages references with FlatHome — The sitemap generator, page-context resolver, SEO-score analyser, and structured-data breadcrumb builder all referenced the obsolete EasyPages plugin. They now target
FlatHomeServiceinstead:getFlatHomePagesUrls()checks forFlatHome\FlatHomeServiceand callsgetAllPages()(published only); the page-context key is renamed fromeasypages:{slug}toflathome:{slug}throughout; the SEO-score content fetch usesFlatHomeService::getPageBySlug(); the breadcrumbcase 'flathome'resolves the page title viaFlatHomeService. Files changed:plugins/FlatSEO/FlatSEOSitemap.php,plugins/FlatSEO/FlatSEOService.php,plugins/FlatSEO/FlatSEOController.php,plugins/FlatSEO/FlatSEOPlugin.php.
Added
- Profile: unsubscribe button in subscriptions tab — Each subscription item now shows a
fa-bell-slashbutton. Clicking it posts toPOST /d/{number_slug}/unsubscribe, fades out the item on success, and decrements the badge counter — without reloading the page. Particularly useful for locked discussions where the user can no longer post to trigger the native unsubscribe flow. Files changed:app/Views/users/profile.php. - Presence: page visit history per user —
PresenceController::writePresence()now maintains apage_historyarray in each user's presence file (stockage/user_presence/{userId}.json), recording the last N distinct pages visited (most recent first). Duplicate consecutive entries are suppressed. The history size is controlled by the newpresence_history_sizecore setting (default: 5; 0=disabled, keeps only the current page). Files changed:app/Controllers/User/PresenceController.php. - Settings → User settings:
presence_history_sizeoption — New numeric field (0–20) in the admin config panel. Displays a storage-aware recommendation: JSON storage → 3–7 pages; SQLite (Pro) → 5–15 pages. On non-Pro installs the field shows a Pro badge linking to the Flatboard Pro resource page and a contextual hint explaining which Pro plugins consume this data, without hiding the option (serves as a feature teaser). Files changed:app/Controllers/Admin/ConfigController.php,app/Views/admin/config.php,languages/{fr,en,de,pt,zh}/admin.json. - Pro — ForumMonitoring: user cards with browsing history in "Active today" — The "Active today" section of the full monitoring page now renders Bootstrap grid cards (3 columns) instead of plain pills. Each card shows the member's username, last activity time, and up to N recently visited pages as clickable links with page-type icons (discussion, category, home, profile, etc.) and truncated titles. Page titles are resolved at read time via
Visitor::getPageInfo(). Members whose presence file predates the update or has expired display "No recent pages". Files changed:plugins/ForumMonitoring/ForumMonitoringService.php,plugins/ForumMonitoring/views/full.php,plugins/ForumMonitoring/langs/{fr,en,de,pt,zh}.json.
Fixed
- Pro — StorageMigrator: comprehensive migration audit and fixes — Conducted a full audit of all data entities. The following were missing from both
JsonToSqliteMigrationandSqliteToJsonMigrationand are now migrated: subscriptions, notifications, drafts, read statuses, post reactions (emoji reactions to posts), user-group assignments, badge definitions, and user badge assignments. Reaction IDs are now properly tracked via$reactionIdMapand$badgeIdMapto maintain referential integrity for post reactions and badge assignments.JsonStorage::createGeneric()was fixed to preserve originalcreated_at/updated_attimestamps during migration (was always overwriting withtime()). NewgetAll*helpers added:getAllSubscriptions(),getAllNotifications(),getAllDrafts(),getAllReadStatus(),getAllPostReactions(),getAllUserBadges(),getAllUserGroupAssignments(). Entities intentionally not migrated: visitors, audit logs, mentions, edit history, and auth tokens (all ephemeral or non-critical for operation). Files changed:app/Storage/JsonStorage.php,app/Storage/SqliteStorage.php,app/Storage/Migrations/JsonToSqliteMigration.php,app/Storage/Migrations/SqliteToJsonMigration.php.
🚀 Changelog — Flatboard 5.4.2
Release date: April 3, 2026
Improved
- Inline CSS minification extended to all theme and plugin views — Every
<style>block in theme view files (premium, bootswatch) and plugin view files now passes its content throughInlineAssetHelper::style()before output, matching the pattern already applied to plugin PHP files. Static CSS blocks use the nowdoc heredoc syntax (<<<'CSS'); dynamic blocks (PHP variable interpolation) use anob_start/ob_get_clean()capture before minification. This ensures all inline CSS across the entire application is minified consistently. Files changed:themes/bootswatch/views/admin/bootswatch.php,themes/bootswatch/views/components/bootswatch-colors.php,themes/premium/views/discussions/attachments-display.php,themes/premium/views/discussions/create.php,themes/premium/views/discussions/edit.php,themes/premium/views/discussions/search.php,themes/premium/views/discussions/show.php,themes/premium/views/discussions/tags.php,themes/premium/views/components/typing-indicator.php,plugins/EasyMDE/views/admin.php,plugins/FlatHome/views/admin/admin.php,plugins/FlatHome/views/admin/page_form.php,plugins/FlatHome/views/admin/pages.php,plugins/ForumMonitoring/views/full.php,plugins/PrivateMessaging/views/admin.php,plugins/PrivateMessaging/views/sent.php,plugins/StorageMigrator/views/admin.php,plugins/TUIEditor/views/admin.php.
Fixed
- Toast:
toast.jsloaded twice andtoast-container.cssinjected in<body>— Thetoast.phpcomponent loaded bothtoast.jsandtoast-container.cssat inclusion time (near</body>), whilefooter.phpindependently loadedtoast.jsagain — resulting in two<script>declarations for the same file and a<link>stylesheet appearing in<body>instead of<head>. Removed the asset loading fromtoast.php(now outputs only the container<div>), and movedtoast-container.cssto the<head>section of all frontend layout headers (premium, ClassicForum, IPB, bootswatch, app default). Files changed:themes/premium/views/components/toast.php,app/Views/components/toast.php,themes/premium/views/layouts/frontend/header.php,themes/ClassicForum/views/layouts/frontend/header.php,themes/IPB/views/layouts/frontend/header.php,themes/bootswatch/views/layouts/frontend/header.php,app/Views/layouts/frontend/header.php. - HtmlMinifier: multi-line HTML attributes collapsed without a separator —
HtmlMinifier::minify()removed newlines with an empty string rather than a space. When a template spread attributes across lines (e.g.id="x"\n class="y"), the step that trims trailing whitespace per line consumed the separating space, and the subsequent newline removal then gluedid="x"directly toclass="y", producing invalid HTML that browsers had to recover from. Fixed by replacing newlines with a single space and then collapsing any resulting double-spaces, so all attribute separators are preserved regardless of how templates are formatted. The Flatbot plugin's own modal markup was also corrected to use single-line attributes as a belt-and-suspenders measure. Files changed:app/Core/HtmlMinifier.php,plugins/Flatbot/FlatbotPlugin.php. - Presence: logged-in user showed no page info on
/usersimmediately after login —LoginController(standard login and 2FA path) only updatedUser::last_activityon successful authentication but never wrote to the presence store, so the user appeared in the online list without a current-page entry until their first heartbeat (~60 s later). Both login paths now callPresenceController::writePresence()at authentication time, initialising the entry with page/. Files changed:app/Controllers/Auth/LoginController.php.
Performance
- Presence: per-user files replace shared
stockage/user_presence.json— The single shared presence file was a write bottleneck under load: every 60 s heartbeat per active user triggered a full read-modify-write cycle viaAtomicFileHelper(≈ 4 I/O operations + 3 JSON encode/decode rounds) on the same file, with a last-write-wins race condition when concurrent users fired simultaneously. Replaced with one small file per user (stockage/user_presence/{userId}.json). Each heartbeat now writes only its own ~80-byte file with a plainfile_put_contents(..., LOCK_EX)— no contention, no double-verification overhead.Visitor::getUserCurrentPage()reads only the single requested user's file instead of the full shared payload;Visitor::getConnectedUserIps()usesfilemtime()as a pre-filter to skip clearly stale files without opening them.page_timeis now correctly preserved across heartbeats when the user stays on the same page. Stale files are purged lazily on read (10 % random trigger) rather than on every write. The oldstockage/user_presence.jsonis no longer created or read. Files changed:app/Controllers/User/PresenceController.php,app/Services/PresenceService.php,app/Models/Visitor.php.
Security
- Registration: MX DNS validation to block fake email addresses — A new option
email_mx_validation(enabled by default) checks at registration time that the email domain has at least one MX record. Addresses whose domain has no mail server (e.g.demo@demo.com) are rejected with a localized error message before any database write. The option can be toggled in Admin → Settings → Email. Translation keys added to all five language files. Files changed:app/Controllers/Auth/RegisterController.php,app/Core/Config.php,app/Controllers/Admin/ConfigController.php,app/Views/admin/config.php,languages/{fr,en,de,pt,zh}/errors.json,languages/{fr,en,de,pt,zh}/admin.json. - Community — Logger: SSRF protection added to webhook delivery —
Logger\WebhookService::send()now resolves the target hostname and rejects requests to private or reserved IP ranges before opening a cURL connection, preventing server-side request forgery via admin-configured webhook URLs. Redirections are also disabled (CURLOPT_FOLLOWLOCATION => false) to prevent redirect-based bypass. Files changed:plugins/Logger/WebhookService.php. - Update installer: redirect following disabled in file download —
UpdateController::downloadFile()usedfile_get_contents()withfollow_location => true, which could follow a redirect from the update server to an internal address. Changed tofalse; the download URL is constructed from a configured base URL and is not redirect-dependent. Files changed:app/Controllers/Admin/UpdateController.php.