Flatboard 5.5.1 "AEGIS" — Changelog

Fred Fred ·18 April 2026 à 13:29·34 min read·14· 0 comment

🚀 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_login is a base-path-stripped URL (as set by AuthMiddleware). LoginController now wraps it with UrlHelper::to() before redirecting, restoring the subdirectory prefix. Response::back() and Controller::back() likewise now use UrlHelper::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_token cookie path hardcoded to / in subdirectory installsSession::setRememberToken() and Session::clearRememberToken() now derive the cookie path from UrlHelper::getBaseUrl(), producing /subfolder/ for subdirectory installs and / for root installs. Files changed: app/Core/Session.php.

Security

  • Core — Open redirect via HTTP_REFERERResponse::back() now validates that the Referer host matches the configured site_url before redirecting; falls back to the $default parameter otherwise. Files changed: app/Core/Response.php.
  • Core — Header injection in Content-DispositionAttachmentController::serveFile() now strips CR, LF, and double-quote characters from filenames before injecting them into the Content-Disposition header, and adds a properly RFC 6266-encoded filename* parameter for Unicode filenames. Files changed: app/Controllers/AttachmentController.php.
  • Core — CSRF protection on POST /searchSearchController::index() now calls verifyCsrf() when handling a POST request. Files changed: app/Controllers/Discussion/SearchController.php.
  • Plugin PrivateMessaging — CSRF on all write API endpointsverifyCsrf() added to send(), toggleStar(), markRead(), delete(), deleteMultiple(), and deleteThread(). Files changed: plugins/PrivateMessaging/PrivateMessagingController.php.
  • Plugin FlatHome — CSRF on all admin POST endpointsverifyCsrf() 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_REFERER truncated before persistenceRequest::getUserAgent() now caps at 512 characters. LogMiddleware and VisitorTrackingMiddleware now cap HTTP_REFERER at 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 through InlineAssetHelper::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_view setting (Admin → Settings → General) lets administrators choose what the forum homepage (/) displays by default: latest discussions (existing behaviour) or the forum categories grid. The URL /forums always shows the categories grid; the URL /discussions always 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 functioninitFormEnhancements() in ux-enhancements.js attached a blur validation listener to every .form-control element, 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 called this.checkValidity(), which doesn't exist on non-form elements. Fixed by skipping elements that don't implement checkValidity(). Files changed: themes/premium/assets/js/ux-enhancements.js.
  • Core — API: autocomplete search never showed resultssearch-form-manager.js read response.results but SearchApiController returned response.data. Autocomplete results were always silently discarded. Fixed by adding results as an explicit alias in the response (alongside data kept 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, SearchController AJAX. 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" buttonload-more-manager.js evaluated hasMore as data.hasMore !== false && items.length === this.perPage. When an API returned an explicit data.hasMore = true with a partial page (fewer items than perPage, e.g. due to permission filtering), the length condition overrode the API value and the button disappeared. Fixed by trusting data.hasMore when explicitly present and using items.length >= perPage only 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 0profile-manager.js updated the button's offset with data.count || 0: if data.count was 0 and data.hasMore was still true, the offset never advanced and each click re-fetched the same (empty) page indefinitely. Fixed by removing the button whenever loadedCount === 0, regardless of hasMore. Files changed: themes/assets/js/frontend/modules/profile-manager.js.
  • Core — AJAX search dropdown: "View all results" always showed (20) — Three separate issues: (1) SearchApiController computed $total = count($allResults) with $searchLimit = $limit (=20), so total was always capped at 20; fixed by calling countSearch() for the total and search($offset+$limit) only for the page; (2) SearchService::searchPaginated() similarly capped the total at MAX_COUNT_FOR_TOTAL = 200; replaced with a dedicated countSearch() 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.js read data.totalResults which SearchApiController never returned (only data.total); fixed by adding a totalResults alias in SearchApiController and adding a data.total fallback in the JS. Additionally formatUserResult() now processes the user bio through extractExcerpt() (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 nothingSearchController AJAX response did not include a html field; load-more-manager.js called appendItems(items, data.html) but data.html was undefined so nothing was appended, and no renderItem fallback was configured. Fixed by: (1) generating HTML in SearchController's AJAX branch using SearchResultFormatter (before sanitizing excerpts, so <mark> highlights are preserved); (2) rewriting SearchResultFormatter::formatItem() to produce the same Bootstrap card structure as search.php (previously it used list-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 languagesendVerification(), sendPasswordReset(), and sendEmailChangeConfirmation() in EmailService had their full HTML bodies hardcoded in French, ignoring the language / default_language config setting and the existing languages/*/emails.json translation files. A new private emailTrans() 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 into buildEmailHtml() 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.jsondeepMergePreserveExisting() in UpdateController used array_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. FlatHome page_groups): because the archive copy and the user's customised copy differ, SORT_REGULAR did 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 new mergeIndexedObjectArrayById() helper that merges by the id field — 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.js looked for users.title.delete_complete but users.title is a string, not an object; fixed by adding users.modal_title.delete_complete to all 5 admin language files and updating the lookup; (2) "Annuler"/"Confirmer" buttons — UIHelpers.js looked for window.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 the date_ prefix from any field key, so the form field date_format was saved under key format while both blog views read date_format — always falling back to relative. Fixed by reading format first with date_format as fallback; the orphaned date_format default removed from plugin.json. (2) The "Recent posts" sidebar widget in article.php called DateHelper::relative() directly instead of the flathome_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 via buildNavList() (view.navbar.items hook), this method ran a glob() + 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 requestgetPendingPosts(), getPendingDiscussions(), and getShadowBans() each performed a glob() + 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 executed User::find() + GroupHelper::isAdmin() + StorageFactory + getGroupPermissions() unconditionally. On a standard page load, the view.navbar.items hook alone triggered ~12 uncached I/O reads just to check two permissions for Private Messaging. Results are now stored in a static $cache array keyed by userId:permissionKey; identical lookups within the same request are answered instantly. A clearCache() 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 cachedgetAllUsers(), getAllDiscussions(), and getPostsByDiscussion() 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 call User::all() / Discussion::all() / Post::byDiscussion(), and all 6 stat calls are wrapped in Cache::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 calladdDashboardWidget() called 6 stat methods; each independently called User::all(10000), Discussion::all(10000), and Post::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 deleteUserManagementController::anonymize() and deleteComplete() called Translator::trans('admin.users.message.anonymized', [], 'admin') and Translator::trans('admin.users.message.deleted_complete', [], 'admin'). The domain is already 'admin', so the Translator looked for a non-existent admin sub-key inside admin.json and returned the raw key string as the toast message. Removed the erroneous admin. prefix. Files changed: app/Controllers/Admin/UserManagementController.php.
  • Core — Purge unverified accounts: admin button + CLI command — Accounts with email_verified = false and created_at older than N days (default 7, the token TTL) can now be purged via the Maintenance panel in the admin dashboard or via php console.php cleanup:unverified-users [days]. Implemented purgeUnverifiedUsers(int $days) in StorageInterface, JsonStorage, and SqliteStorage. 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 5 languages/*/admin.json.
  • Admin — Users: email-verified filter now appears when email verification is enabledadmin/users.php was reading email.verification (non-existent key) instead of email_verification, so $emailVerificationConfigured was always false and the filter dropdown was never rendered. Removed the redundant variable and reuse $emailVerificationEnabled directly. Files changed: app/Views/admin/users.php.
  • Members list: unverified users no longer appear on the public /users pageUserController::index() and UserController::search() loaded all users via User::all() without filtering out accounts with email_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 countslatestMembers, total_users, and active_members now filter out accounts with email_verified = false across 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 contentgetEditorValue() hook in EasyMDEPlugin.php was missing a return statement. The hook script assigned to an implicit variable instead of returning a value, so the JS function in markdown-editor.php returned undefined on every auto-save cycle; the server stored null content. Fixed by returning editor.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 ($typingContext etc. instead of $context etc.), causing the component to exit immediately on empty $contextId. (2) The context value 'pm' is not in the server's ALLOWED_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 fetch interceptor in toast.js automatically shows a toast for every 4xx/5xx response, but plugins-management.js also shows one in its own catch block. Fixed by adding /admin/plugins/toggle and /admin/plugins/uninstall to the manualErrorRoutes list 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.php renders 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, and discussions/show.php. Two new theme settings added: show_sidebar_stats (show/hide the entire block) and show_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 old show_stats / stats_display_mode inline rendering has been removed from the individual views. — The sidebar column in discussions/index.php (including when browsing a specific category), categories/index.php, and tags.php now all display an online block showing member avatars and the guest count. In discussions/index.php the block appears as a standalone card whenever the full compact stats block is not shown (category selected, stats disabled, or non-compact mode). In tags.php the block is always present in the sidebar when show_user_presence is enabled. Guest count fetched via PresenceService::getActiveAnonymousVisitorsDetailed(). New translation key common.stats.online_guests added 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.php component 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 a fa-user-secret icon. 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 formattingSearchService::extractExcerpt() now parses the markdown to HTML, strips <pre> code blocks (noise in excerpts), then reduces the result to plain text via strip_tags() + html_entity_decode() before truncating. This prevents raw markdown syntax (](url), `) and malformed <strong>/<em> tags in stored rendered_html from bleeding bold or italic across the entire excerpt. Files changed: app/Services/SearchService.php.
  • Core — Search: excerpts use the first post's cached rendered_htmlformatDiscussionResult() now calls Post::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 to MarkdownHelper::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. Reduces MarkdownHelper::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 slugformatDiscussionResult() and formatPostResult() used ?? to fall back to Sanitizer::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-link to 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 to position: relative; z-index: 1 so 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 through InlineAssetHelper — The raw <style> block in app/Views/discussions/search.php was not going through InlineAssetHelper::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 $blogCatName on pages listFlatHomePageController::adminPages() did not pass $blogCatName to the pages.php view, yet the view used it unconditionally. The variable is now computed at the top of the view from the already-available $settings array (same pattern as admin.php): looks up the blog category by settings.blog_category and 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 blockedPluginController::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 calls PluginHelper::checkDependencies() before writing active = 1; if the check fails, a JSON error is returned with a new key plugins.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 visitPlugin::disableIfIncompatible() re-set the auto_disabled_incompatible flag on every boot whenever the flag was absent and the plugin was incompatible — even if the plugin was already active = 0. Because PluginController::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 in disableIfIncompatible() 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.flatboard version constraint is not satisfied by the running Flatboard version are now automatically disabled at boot time (Plugin::disableIfIncompatible()). The active field is set to 0 in plugin.json and a one-shot flag auto_disabled_incompatible is written. On the next visit to Admin → Plugins, PluginController reads 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-exclamation icon, 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 checkThemeController::getThemes() now evaluates requires.flatboard in theme.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 of Admin → Themes when 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 whose requires.flatboard (or requires.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 by PluginHelper::checkDependencies(), which previously handled PHP and extension constraints but never checked the flatboard version 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 by PluginSettingsController::index() just before rendering the standard settings page (/admin/plugins/settings?plugin=…). Passes $formConfig by reference and $pluginId as a second argument, allowing plugins to inject dynamic options (e.g. group lists) into select fields defined in their form_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 uniquenessBaseImporter::uniqueSlug() ran a SELECT COUNT(*) in a while loop for every slug to verify. All existing values are now loaded in a single SELECT on 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 checkupsertUser() ran SELECT 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 importUsers step of each importer loaded all users into RAM in a single fetchAll() query. All importers are now paginated at 500 users per batch (same mechanism as discussions/posts). The controller recognises done=false and automatically replays the step. Files changed: plugins/ForumImporter/Importers/BaseImporter.php, plugins/ForumImporter/ForumImporterController.php, all importers.
  • Pro — ForumImporter: incorrect is_first_post during finalization — The SELECT MIN(id) query on UUID v4 (random) values produced a lexicographic result unrelated to chronological order. Replaced with MIN(rowid) (SQLite auto-incremented integer, reflecting actual insertion order). Finalization first resets all is_first_post to 0, then marks only the lowest rowid per discussion. Files changed: plugins/ForumImporter/ForumImporterController.php.
  • Pro — ForumImporter: GROUP BY MIN() for is_first_post stored in session (phpBB, MyBB) — phpBB and MyBB loaded all MIN(post_id) GROUP BY topic_id into the PHP session to determine the first post of each topic. Replaced with a LEFT JOIN on phpbb_topics.topic_first_post_id / mybb_threads.firstpost directly 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_at updated post by post — Each inserted post immediately triggered an UPDATE 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_user loaded globally — The group_user table was fully loaded into memory before processing users, then stored in the session. With user pagination, it is now loaded per batch via WHERE user_id IN (...), without exhausting the session. Files changed: plugins/ForumImporter/Importers/Forums/FlarumImporter.php.
  • Pro — ForumImporter: Discourse — unquoted reserved word primary — The join user_emails e ON ... AND e.primary = true used primary without quotes, which is syntactically invalid in PostgreSQL (primary is a reserved word). Fixed as e."primary". The query was rewritten to join user_profiles in the same statement (bio + website), removing two separate global SELECTs. Assignment to non-automatic groups via group_users is 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 with unserialize($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_sig mapped to wrong field — The phpBB signature (user_sig, displayed below posts) was passed to the bio parameter of upsertUser() instead of signature. Fixed: user_sig → Flatboard signature field; bio remains null (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 than FULL in WAL mode), PRAGMA cache_size = -32000 (32 MB page cache), and PRAGMA temp_store = MEMORY. Added to getSqliteDb(). 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.items was always visible regardless of group permissions. FlatHome also suppresses the theme's native link via CSS (data-fh selector), making it the sole source of this entry. The link is now hidden when the group has neither profile.view nor presence.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: 913 caused a slow theme.setting.value hook (457 ms) — The stat_posts shortcode callback used the deprecated Post::all() to count posts, loading every post file on a cache miss. On JSON storage this now sums the denormalized post_count field from the discussions cache (already in memory — no post files read); on SQLite it runs SELECT COUNT(*) FROM posts. Added countAllPosts() to StorageInterface, JsonStorage, and SqliteStorage. The stat_discussions shortcode also lacked a per-request cache and called Discussion::count() on every invocation; a static $cached guard 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 updatedeployUpdateFiles() used AtomicFileHelper::readAtomic() to read plugin.json files before merging them. If the file lock could not be acquired within the 2-second timeout (e.g. under PHP-FPM load), the method returned null, the !is_array() guard was triggered, and a raw copy() was performed — silently overwriting the installed plugin's configuration with the archive defaults. Replaced with direct file_get_contents() + json_decode() reads (safe during updates since maintenance mode is active) and an atomic file_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 finalizing completes — 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 FlatHomeService instead: getFlatHomePagesUrls() checks for FlatHome\FlatHomeService and calls getAllPages() (published only); the page-context key is renamed from easypages:{slug} to flathome:{slug} throughout; the SEO-score content fetch uses FlatHomeService::getPageBySlug(); the breadcrumb case 'flathome' resolves the page title via FlatHomeService. 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-slash button. Clicking it posts to POST /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 userPresenceController::writePresence() now maintains a page_history array 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 new presence_history_size core setting (default: 5; 0=disabled, keeps only the current page). Files changed: app/Controllers/User/PresenceController.php.
  • Settings → User settings: presence_history_size option — 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 JsonToSqliteMigration and SqliteToJsonMigration and 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 $reactionIdMap and $badgeIdMap to maintain referential integrity for post reactions and badge assignments. JsonStorage::createGeneric() was fixed to preserve original created_at/updated_at timestamps during migration (was always overwriting with time()). New getAll* 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 through InlineAssetHelper::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 an ob_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.js loaded twice and toast-container.css injected in <body> — The toast.php component loaded both toast.js and toast-container.css at inclusion time (near </body>), while footer.php independently loaded toast.js again — resulting in two <script> declarations for the same file and a <link> stylesheet appearing in <body> instead of <head>. Removed the asset loading from toast.php (now outputs only the container <div>), and moved toast-container.css to 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 separatorHtmlMinifier::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 glued id="x" directly to class="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 /users immediately after loginLoginController (standard login and 2FA path) only updated User::last_activity on 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 call PresenceController::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 via AtomicFileHelper (≈ 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 plain file_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() uses filemtime() as a pre-filter to skip clearly stale files without opening them. page_time is 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 old stockage/user_presence.json is 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 deliveryLogger\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 downloadUpdateController::downloadFile() used file_get_contents() with follow_location => true, which could follow a redirect from the update server to an internal address. Changed to false; the download URL is constructed from a configured base URL and is not redirect-dependent. Files changed: app/Controllers/Admin/UpdateController.php.

Share this article:

Fred

👨‍💻 Flatboard Founder 🔧 Flatboard Core Developer.
Full-Stack Web Developer
Expert in Portable and Interoperable Solutions (PHP/JSON)

Member since December 2025

0 comment

No comments yet. Be the first!

Log in to leave a comment.