Flatboard 5.6.0 "BASTION" — what changed since 5.5.1 "AEGIS"
Six weeks. Fourteen releases. One version bump. Here's everything between AEGIS (5.5.1) and BASTION (5.6.0), grouped by what it means in practice rather than which minor it landed in.
🔒 Security — why "BASTION"
This cycle opened with a focused audit. Five issues, all fixed before 5.6.0 shipped.
Two methods in SqliteStorage — count() and createGeneric() — were interpolating caller-supplied table names directly into raw SQL with no validation. Both now check $table against an explicit whitelist; column names in conditions go through a regex before touching the query.
updateUser() and updateDiscussion() had a mass assignment problem: both built SET col = :col clauses for every key in the caller's array, including is_banned, group_id, api_token, and password_hash. A compile-time whitelist now silently drops anything unrecognised before the query runs.
FlatHome was loading CMS page templates from a path built directly from the template field in JSON. A crafted value could escape the plugin directory. The field is now stripped to [a-zA-Z0-9_-] and run through basename() before any path is constructed.
In buildCategoryFilter(), category IDs going into an IN (...) clause were passed to PDO::quote() unchecked. Each ID is now validated; an empty safe-set produces (1=0) instead of broken SQL.
Finally, two extract() calls in PluginViewController and PluginCard::renderAdminView() now pass EXTR_SKIP, so plugin hook data can no longer quietly overwrite local view variables.
⚡ Performance
Performance work ran the full six weeks, targeting the paths hit most often.
Permission checks are now cached for the lifetime of the request. Every PermissionHelper::can() call used to trigger User::find(), a group lookup, and a permissions read — every single time. On a standard page load, the nav hook alone caused ~12 uncached I/O operations just to verify two permissions for Private Messaging. Same request, same lookup? Now it's instant.
Tag pages were loading up to 10,000 full discussion objects to count how many matched a tag. A dedicated countDiscussionsByTag() method now reads only the index files. The tag visibility filter, which called getDiscussionsByTag() once per tag (N round-trips), now retrieves all tag-to-category mappings in one pass. Category::canView() is memoised within the call.
Profile pages made two passes over all discussions — one for posts, one for reactions — doubling disk reads. Both controllers now do it in one.
FlatModerationExtend shadow ban and pre-moderation views were calling User::find() in a loop, one query per entry. They now collect all IDs first and load in a single User::findMany() batch. The moderator notification method called GroupHelper::isAdmin() and GroupHelper::isModerator() for every user, each internally re-loading user and group data. It now resolves the relevant group IDs once and filters by $user['group_id'] directly.
Admin export queries for posts and notifications were N+1 loops. Both now run a single query on SQLite. The JSON fallback keeps the loop but drops the O(n²) array_merge.
Forum Monitoring (Pro) had the worst of it: stat helpers were calling themselves recursively, causing infinite loops and memory exhaustion on busy forums. Fixed, and all six stat calls are wrapped in a 5-minute cache.
One cleanup worth mentioning: every method_exists($storage, …) guard in the codebase is gone. These wrapped methods declared in StorageInterface, so they were always true. Dead code, removed.
⏱️ Content time limits
Admins can now set how long members have to edit or delete their own posts. The settings live in a new Contenu tab in the admin config panel, with separate controls for discussions and replies: edit and delete windows, configured independently.
The defaults are 60 minutes to edit, 30 minutes to delete. The help text for each field includes a reference table: Discourse runs 1,440 minutes (24h) by default, XenForo and phpBB ship unlimited, vBulletin lands at 30–60 min. Something concrete to compare against rather than a blank field.
There's also a "lock on reply" mode, on by default. Once any reply is posted, the original poster can no longer edit the discussion — same as NodeBB. For replies, only the most recent post in the thread stays editable. This works alongside the time limit. While no reply exists, the minute window applies. Once a reply lands, editing locks immediately.
A fourth toggle blocks members from deleting a discussion that already has replies, also on by default.
When a limit kicks in, the member gets a specific error message, not just "permission denied".
Moderators bypass all of it. Existing installs pick up the defaults automatically on the first load after updating. No migration, no config edits.
💬 Guest call-to-action
Guests who finish reading a discussion now see a dismissible alert below the last reply: "You're enjoying this discussion — but you can't reply yet." Two buttons — Create a free account and Log in — with the login pre-filled with a ?redirect= so they land right back on the discussion. Closing the alert clears it for the visit. Logged-in users never see it. Works across all themes, translated in all 5 languages.
👥 Members list
The /users page got simpler and more useful at the same time.
Offline member cards now show "Last visit: X ago". Online members still show their current page and session length. The online/offline filter has been replaced by a dedicated "Online" tab, populated server-side (covering all pages, not just the current one), with a live count badge next to the label.
Two things removed: the member text search bar, which duplicated the global search with inconsistent results, and the status badge block in the banner, which just echoed the tab counters.
The page also has a proper permission gate now. Any visitor could previously reach /users directly via URL regardless of profile.view or presence.view permissions. The nav link in the header applies the same check.
🏷️ Tags
The /tags page was a flat badge cloud with no useful information. It's now a responsive card grid — up to 5 columns on desktop — with the tag icon in its colour, the tag name, and the discussion count below. Sorted by activity, so live topics come first. The coloured left-accent bar is kept, as is the admin/mod delete button with inline confirmation.
📊 Sidebar stats (Premium theme)
A shared sidebar-stats.php component now puts the full stats block in the sidebar of every view that has a column: categories grid, discussion list (including category-filtered), tags page, and individual discussions. Two theme settings control it: show_sidebar_stats and show_sidebar_guests.
The block itself has been redesigned: a two-column grid for the four counters, a compact "New member" row, and a unified "Online" section with a green dot and guest count inline.
📦 GDPR export
The GDPR export in ExportService had several real problems, all fixed in 5.5.2 following an Article 20 data portability audit.
getUserPosts()only scanned discussions the user created — replies in other members' discussions weren't included at all.notification_preferencesandpreferenceswere exported as JSON strings inside the JSON archive. Double-encoded.getUserReactions()always returned empty despite a working implementation in storage.- Fields removed from the export: moderator action fields (
pinned_by,locked_by,solved_by,best_answer_set_by) expose third-party personal data;rendered_htmlis a derived field that accounted for ~53% of the archive size;edited_byexposed a moderator's identity. exported_atwas in server local time instead of UTC.- The rate-limit exception was logged at
ERRORinstead ofWARNING. The archive's README now lists every section and its item count.
✏️ Markdown and editors
Tables needed three rounds of fixes. The original separator-row regex only allowed
-,:, and spaces, so any table with multiple columns — whose separator row contains|dividers — would silently fail to render. The first fix introduced a PCRE range bug ([|:- ]is an invalid descending range in PHP's regex engine). Then blank-line injection added for EasyMDE and TUIEditor was too broad, hitting table rows and breaking separator detection. All three are done.
The Markdown cache rebuild in the admin panel previously skipped posts with a non-emptyrendered_html, even if it was built before a parser fix. It now re-renders every post unconditionally.
EasyMDE draft auto-save was silently storing empty content. A missingreturnstatement caused the JS hook to receiveundefinedon every cycle.
🔧 Notable fixes
CSRF. Controller::verifyCsrf() only checked $_POST and HTTP headers. Fetch requests with JSON bodies have no $_POST data, and custom headers can be stripped by Apache + PHP-FPM. The controller now also reads the CSRF token from the JSON body. Header parsing was fixed to read $_SERVER['HTTP_*'] first and supplement with getallheaders(), which FastCGI setups may not forward.
Subdirectory installs. Login redirects, "remember me" cookie paths, and the installer's existing-install redirect all hardcoded / as the base path. All three now use UrlHelper::getBaseUrl().
System emails. Verification, password reset, and email-change confirmation emails were hardcoded in French, ignoring both the site language setting and the existing emails.json files. They now use a private emailTrans() helper that reads the correct language file directly.
Load more. Two bugs. The hasMore evaluation in load-more-manager.js overrode an explicit data.hasMore: true from the API when a partial page came back due to permission filtering, hiding the button too early — the JS now trusts the API value when set. Separately, "Load more" on tag pages always returned 400: route parameters like {slug} were extracted by the Router but never injected into the Request object, so $request->get('slug') returned null. Fixed by adding Request::setRouteParams(), called before the controller is instantiated. Covers all API controllers.
Search. Autocomplete discarded all results because the JS read response.results while the API returned response.data. The "View all results (N)" count was capped at 20 for the same reason. Excerpts no longer contain raw Markdown or bleeding inline formatting — extractExcerpt() parses to HTML, strips code blocks, then reduces to plain text before truncating. The search pipeline now sorts and slices to the page limit before loading related data.
Homepage view. A homepage_view setting lets administrators choose whether / shows the discussion list or the categories grid. /discussions and /forums are permanent routes and always resolve to their respective views regardless of this setting.
Discussion form. The rate-limit toast and creation-error message were hardcoded in French in the JS. Both now read from server-side translation keys. Rate-limit detection via errorMessage.includes('patienter') — which only worked in French — is replaced by response.status === 429.
Unverified accounts. Accounts with email_verified = false older than N days can be purged from the Maintenance panel or via php console.php cleanup:unverified-users [days]. Unverified accounts are excluded from the public members list, latest-member display, and all user counts.
🏠 FlatHome (Pro)
- Setup guide. A "Getting started" card appears on the FlatHome admin tab when the config is incomplete: blog enabled without a category, or CMS homepage with no published pages. It goes away once both are resolved.
- Dynamic settings. Blog-related options hide when the blog is disabled. They stay in the DOM and keep their values, so toggling doesn't reset anything.
- CMS pages. The author name and avatar block is removed from the top of CMS pages. It makes sense on blog articles; it doesn't on a Contact page.
/usersnav item. No longer gated onforum_enabled. CMS-only installs can show the members page in navigation independently.- Active class false-positive. The users nav link was highlighted when browsing
/admin/usersbecausestrpos($uri, '/users')matched. Fixed withpreg_match('#^/users(/|$)#', …).
📦 Package summary
| Package | What changed |
|---|---|
| Community | EasyMDE: draft auto-save, table parsing · Content time limits: edit/delete windows for discussions and replies |
| Pro | FlatHome: setup guide, dynamic settings, CMS cleanup, nav fixes · FlatModerationExtend: N+1 queries, permission cache · Forum Monitoring: infinite loop fixed, stats cached |
⬆️ Updating
Update via Admin > Updates. No database migration. JSON and SQLite both supported.
Running FlatModerationExtend? It updates alongside core automatically.
📋 Full changelog
The complete changelog with all technical details is available in full changelog thread.
Thank you to everyone who reported bugs and contributed to this release. 🙏