🚀 Changelog — Flatboard 5.6.0 "BASTION"
Release date: May 17, 2026
Security
- SQL injection:
SqliteStorage::count()— table and column names not validated — The$tableparameter and condition keys were interpolated directly into the SQL query without any validation.$tableis now checked against an explicit whitelist (COUNTABLE_TABLES); condition column names are validated with a/^[a-zA-Z_][a-zA-Z0-9_]*$/pattern before inclusion in the query.
Files changed:app/Storage/SqliteStorage.php. - SQL injection:
SqliteStorage::createGeneric()— table name not validated — The private helper method accepted any table name via its$tableparameter and interpolated it directly into theINSERTstatement.$tableis now checked against an explicit whitelist (GENERIC_TABLES); column names from$dataare also validated with a regex before use.
Files changed:app/Storage/SqliteStorage.php. - Mass assignment:
updateUser()andupdateDiscussion()— all columns accepted without filter — Both methods iterated over the caller-supplied$dataarray and builtSET col = :colclauses for every key, including sensitive columns (is_banned,group_id,is_admin,api_token,password_hash). Each method now filters$dataagainst a compile-time whitelist (USER_UPDATABLE_COLUMNS,DISCUSSION_UPDATABLE_COLUMNS) and silently discards unrecognised keys.
Files changed:app/Storage/SqliteStorage.php. - Path traversal: FlatHome template field not sanitised — The
templatefield of a CMS page (stored in JSON) was used verbatim to construct anincludepath (modeles/{template}.php). A value like../../app/Configcould reach files outside the plugin directory. The field is now passed throughbasename()and stripped of any character outside[a-zA-Z0-9_-]before path construction. The same sanitisation is applied to theslugfallback and to the boot-time template detection inFlatHomePlugin.php.
Files changed:plugins/FlatHome/views/page.php,plugins/FlatHome/FlatHomePlugin.php. buildCategoryFilter()— IDs not validated before use inPDO::quote()— Category IDs passed to theIN (...)clause were quoted viaPDO::quote()without any prior validation. Each ID is now checked against/^[a-zA-Z0-9_-]+$/before quoting; an empty safe-set returns the always-false fragment(1=0)instead of an invalid SQL clause.
Files changed:app/Storage/SqliteStorage.php.Improved
- Discussion page: guest call-to-action — Guests viewing a discussion now see a Bootstrap dismissible alert below the last post, with a register button and a login button pre-filled with a
?redirect=to the current discussion. Covered for all themes: the premium theme andapp/Views(shared by ClassicForum, IPB, NordTheme, bootswatch, default). Translated in all 5 languages viadiscussion.guestBanner.{title,register,login}.
Files changed:themes/premium/views/discussions/show.php,app/Views/discussions/show.php,languages/{fr,en,de,pt,zh}/main.json. - Users list: last presence displayed on member cards — Each member card on
/usersnow shows the last activity date ("Dernière visite : il y a X") for offline users, using the existingprofile.view.lastSeentranslation key andDateHelper::ago(). Online users continue to show their current page and active duration instead.
Files changed:app/Views/users/_user_card_modern.php. - Users list: removed redundant text search and banner stat badges; added "Online" tab — The member search bar (duplicated global search) and the presence stat badges in the page banner have been removed. The online/offline filter is replaced by a dedicated "Online" tab showing all currently connected members, fed from a server-side computed list so it covers all pages (not just the visible one). The tab badge displays the live online count.
Files changed:app/Controllers/User/UserController.php,app/Views/users/list.php,languages/{fr,en,de,pt,zh}/main.json.Fixed
- Content limits: "lock on reply" mode for editing (NodeBB-style) — Two new boolean settings pair with the existing minute-based edit windows:
discussion.edit_requires_no_replieslocks a discussion the moment any reply lands;post.edit_requires_no_replieslocks a reply once a newer one has been posted in the same thread (only the most recent reply stays editable). The logic is additive: while no reply exists, the time limit governs; the instant one arrives, editing locks regardless of any configured window. Both checks run inDiscussionController,PostController, andPostPermissionChecker; moderators are exempt. All three flags default totrueon fresh installs and after update. New error keyspermission.discussionEditRequiresNoRepliesandpermission.postEditRequiresNoRepliesgive members a specific message instead of a generic denial. All 5 language files updated.
Files changed:app/Core/Config.php,install.php,app/Controllers/Admin/ConfigController.php,app/Services/PostPermissionChecker.php,app/Controllers/Discussion/DiscussionController.php,app/Controllers/Discussion/PostController.php,app/Views/admin/config.php,languages/{fr,en,de,pt,zh}/admin.json,languages/{fr,en,de,pt,zh}/errors.json. - Local update duplicates FlatHome page groups —
UpdateController::mergeJsonFile()useddeepMergePreserveExisting()to reconcileplugin.jsonfrom the archive with the installed version. When the archive was built from the developer's environment, itspluginsection contained the developer's ownpage_groups(with different IDs than the user's groups).mergeIndexedObjectArrayById()saw those IDs as absent from the user's data and added them — resulting in duplicate groups after every local update. Fixed by restoring the installedpluginsection verbatim after the deep merge: thepluginkey is user-owned data (settings,page_groups,nav_order), and any new config keys introduced by a plugin update are handled by the plugin's PHP code via runtime defaults.
Files changed:app/Controllers/Admin/UpdateController.php. - "Load more" on tag pages shows error toast despite available discussions —
TagApiController::getDiscussions()calledvalidateSlug()which reads$request->get('slug'), but route parameters ({slug}in/api/tags/{slug}/discussions) were never merged into the Request object — only$_GET/$_POSTwere. The slug was alwaysnull, the validator returned 400, and the JS load-more handler converted the HTTP error into a toast. Fixed by addingRequest::setRouteParams()and calling it fromRouter::executeControllerMethod()before instantiating the controller. This fix covers all API controllers that read route parameters via$request->get()(DiscussionApiController,UserApiController,CategoryApiController,PostApiController,TagApiController).
Files changed:app/Core/Request.php,app/Core/Router.php. - Users page accessible without permission check —
UserController::index()had no permission gate: any visitor could reach/usersdirectly via URL regardless ofprofile.vieworpresence.viewpermissions. The controller now returns HTTP 403 immediately if neither permission is granted.
Files changed:app/Controllers/User/UserController.php. - Users nav link visible in header regardless of permissions — The
/userslink inheader.phpwas rendered unconditionally, without checking whether the current visitor heldprofile.vieworpresence.view. It now applies the same guard as the FlatHome-managed link.
Files changed:themes/premium/views/layouts/frontend/header.php. - FlatHome:
/usersnav item gated onforum_enabled—show_users_in_navwas only honoured whenforum_enabledwas also true, preventing the users link from appearing on installations that run FlatHome in CMS-only mode. The two settings are now independent.
Files changed:plugins/FlatHome/FlatHomeService.php. - FlatHome: "active" class on users link matched
/admin/users—strpos($uri, '/users')returned true for/admin/users, causing the users nav link to appear highlighted when browsing the admin panel. The check is nowpreg_match('#^/users(/|$)#', ...), which only matches the public route. Fixed in both the top-level nav and the dropdown renderer.
Files changed:plugins/FlatHome/FlatHomePlugin.php. - Plugin EasyMDE 2.3.9 / TUIEditor 1.3.8 — Tables broken when content has CRLF line endings — The blank-line injection regex
([^|\n])\n(\|)matched any character that is not|or\nbefore a newline. When content is saved with Windows-style\r\nline endings (e.g. pasted from a Windows client or saved by certain editors), the\rbefore each\nmatched[^|\n], causing a blank line to be injected between every table row. Parsedown then rendered each row as a standalone paragraph and the table appeared as raw pipe-delimited text. Fixed by normalising\r\n→\n(and bare\r→\n) at the very start of preprocessing, before any other regex runs.
Files changed:plugins/EasyMDE/libs/EasyMDEHelper.php,plugins/TUIEditor/libs/TUIEditorHelper.php.Improved
- Tags list: NodeBB-style compact card grid with discussion count — The tags page (
/tags) was a flat badge cloud with no usage information. It is now a compact responsive card grid (5+ columns on desktop): each card shows the tag icon in its colour, the tag name, and the discussion count below using the existingcommon.label.discussion(s)translation keys. Tags are sorted by discussion count descending. The coloured left-accent bar preserves Flatboard's per-tag colour. The admin/mod delete button with inline confirmation is retained and adapted to the new card layout. The frontend controller now callscountAllDiscussionsByTag()to enrich each tag and sort them before passing to the view.
Files changed:app/Controllers/Discussion/TagController.php,themes/premium/views/discussions/tags.php,app/Views/discussions/tags.php. - Content time limits: configurable edit and delete windows for discussions and replies — Admins can set separate minute-based windows for editing and deleting discussions and replies, plus a checkbox to block deletion of discussions that have replies. Defaults: 60 min to edit, 30 to delete (Discourse runs 1,440 by default; XenForo and phpBB ship unlimited; vBulletin is 30–60). Moderators are never subject to these limits. Settings live in the Contenu tab of the admin config panel.
PostPermissionChecker::canEdit()andcanDelete()now return['allowed' => bool, 'errorKey' => string]instead of a bare bool; each rejection carries a specific translation key. The five new config keys (discussion.edit_time_limit,discussion.delete_time_limit,discussion.delete_requires_no_replies,post.edit_time_limit,post.delete_time_limit) are added toConfig::getDefaults()— existing installs pick them up automatically on the first load after update, no migration needed.
Files changed:app/Services/PostPermissionChecker.php,app/Controllers/Api/PostApiController.php,app/Controllers/Discussion/PostController.php,app/Controllers/Discussion/DiscussionController.php,app/Controllers/Admin/ConfigController.php,app/Views/admin/config.php,app/Core/Config.php,install.php,languages/{fr,en,de,pt,zh}/admin.json,languages/{fr,en,de,pt,zh}/errors.json.Performance
- FlatModerationExtend: N+1 queries in shadow ban and pre-moderation list views —
shadowbanList()calledUser::find()in a loop (one query per banned user).premoderationList()calledUser::find()andDiscussion::find()in two separate loops. Both methods now collect all required IDs upfront and load users in a singleUser::findMany()batch call; discussions are loaded with onegetDiscussion()call each after deduplication. A duplicatePermissionHelper::can()check inrequireModerationAccess()has also been removed.
Files changed:plugins/FlatModerationExtend/FlatModerationExtendController.php. - FlatModerationExtend: O(n)
isAdmin/isModeratorcalls in pre-moderation notifier —notifyModeratorsPremod()calledGroupHelper::isAdmin()andGroupHelper::isModerator()for every user; each call internally re-loaded user and group data. The method now resolves the admin and moderator group IDs once viaGroupHelper::getAdminGroupId()/getModeratorGroupId()and filters by$user['group_id']directly, reducing the notification loop to a single in-memory comparison per user.
Files changed:plugins/FlatModerationExtend/FlatModerationExtendPlugin.php. - N+1 queries:
getAllPosts()in admin export — one query per discussion — The privategetAllPosts()helper loaded all discussions and then calledgetPostsByDiscussion()for each one (N+1 filesystem or SQL operations). On SQLite, it now executes a singleSELECT * FROM postsdirectly. The JSON-storage fallback retains the loop but avoids the O(n²)array_mergeby appending rows one at a time.
Files changed:app/Controllers/Admin/UserManagementController.php. - N+1 queries:
getAllNotifications()in admin export — one query per user — The privategetAllNotifications()helper loaded all users and then calledgetUserNotifications()for each one. On SQLite, it now executes a singleSELECT * FROM notificationsdirectly. The JSON-storage fallback similarly avoidsarray_mergein a loop.
Files changed:app/Controllers/Admin/UserManagementController.php. Post rendering: serve
rendered_htmlfrom cache when content hash matches —post-thread.phpwas re-parsing every post's Markdown on every page view viaMarkdownHelper::parse(), even when the storedrendered_htmlwas already up to date. Posts now serve the pre-rendered HTML directly whencontent_hashmatchessha1($content), falling back to live parsing only when the hash is absent or stale (e.g. after an in-place edit without a save). After a plugin update that changes rendering, run Rebuild Markdown Cache from the admin panel to refreshrendered_htmlfor all posts.
Files changed:themes/premium/views/components/post-thread.php,app/Views/components/post-thread.php.🚀 Changelog — Flatboard 5.5.11
Release date: May 16, 2026
Fixed
- Bot detection: duplicate
\bclaudebot\bpattern inisBot()— The regex for ClaudeBot appeared twice consecutively in$botPatterns(lines 283 and 285). The duplicate has been removed.
Files changed:app/Models/Visitor.php. - Bot detection:
applebot-extendednot matched byisBot()—applebot-extendedwas listed inextractBotName()with its display name but had no corresponding entry inisBot(), so Apple's extended crawler was never classified as a bot. A dedicated/applebot-extended/ipattern has been added before the generic/\bapplebot\b/ientry.
Files changed:app/Models/Visitor.php. Bot naming:
netsystemsresearchnot resolved inextractBotName()—NetSystemsResearchwas recognized as a bot byisBot()but had no entry inextractBotName(), causing it to fall back to "Bot inconnu". It now resolves to "Net Systems Research".
Files changed:app/Models/Visitor.php.🚀 Changelog — Flatboard 5.5.10
Release date: May 6, 2026
Fixed
- Guest presence: false-positive login detection triggers spurious API calls —
isUserLoggedIn()inpresence-manager.jsandupdatePresence()inmain.jsuseddocument.querySelector('[data-user-id]')to detect logged-in users, but this selector also matches post avatar<img>elements on the discussion show page, causing both functions to treat guests as authenticated. Additionally, the selectors#user-menuand.user-dropdown[data-bs-toggle="dropdown"]referenced by these checks do not exist in the theme HTML. The detection now exclusively checks for#userDropdownand#notificationDropdown, which are only rendered for authenticated users.
Files changed:themes/assets/js/frontend/modules/presence-manager.js,themes/assets/js/main.js. Toast.js: typing-indicator route not silenced on network errors — The silent-404 pattern
/\/api\/typing(\/|$)/intoast.jsdid not match/api/typing-indicator/...URLs, meaning fetch errors from the typing-indicator endpoint could surface as unexpected toasts. The pattern is now/\/api\/typing(-indicator)?(\/|$)/to cover both forms.
Files changed:themes/assets/js/shared/toast.js.🚀 Changelog — Flatboard 5.5.9
Release date: May 5, 2026
Fixed
- Discussion form: rate-limit message hardcoded in French — The "please wait N seconds" toast shown on HTTP 429 responses was a French string embedded directly in
discussion-form-manager.js. It now readsdiscussionFormConfig.translations.rateLimitMessage, populated server-side viaTranslator::trans('rateLimit.discussion.cooldown')increate.php. The fallback creation-error message was also hardcoded French; it now reads fromconfig.translations.createError.
Files changed:themes/premium/views/discussions/create.php,themes/assets/js/frontend/modules/discussion-form-manager.js. - Discussion form: fragile French-only rate-limit detection —
errorMessage.includes('patienter')was used as a secondary check to detect rate-limit responses. This fallback only matched French and silently failed for all other languages. The check has been removed;response.status === 429alone is correct and language-independent.
Files changed:themes/assets/js/frontend/modules/discussion-form-manager.js.Improved
- Discussion form: missing i18n keys in default theme —
app/Views/discussions/create.php(default theme) was missing therateLimitMessageandcreateErrortranslation entries added to the premium theme'sdiscussionFormConfig, leaving users on the default theme with hardcoded fallbacks.
Files changed:app/Views/discussions/create.php. Remove debug
console.logstatements from production JS and views — Development-only logging (including 100 statements indiscussions/show.phpand emoji-heavy debug output ininfinite-scroll.js) has been cleaned from all frontend modules and view files. Remainingconsole.logcalls are limited to defensive fallbacks that fire only when the Toast system is unavailable.
Files changed:app/Views/discussions/show.php,app/Views/components/report-modal.php,app/Views/components/attachments.php,themes/assets/js/frontend/modules/discussion-form-manager.js,themes/assets/js/frontend/modules/post-manager.js,themes/assets/js/frontend/components/infinite-scroll.js,themes/assets/js/frontend/components/lazy-loader.js,themes/assets/js/frontend/frontend-bundle.js.🚀 Changelog — Flatboard 5.5.8
Release date: May 5, 2026
Fixed
- Installer: redirect ignores subdirectory after existing-install detection — When
config.jsonwas already present, the installer redirected to the hardcoded path/, which is wrong for subdirectory installations (e.g./mysite/). The redirect now callsdetectBaseUrlDuringInstall()and produces the correct base path.
Files changed:install.php. - Installer: duplicate prerequisite check block — The PHP version check, extension check, directory creation, and writable checks were copy-pasted twice, the second occurrence being unreachable dead code. The duplicate block (~86 lines) has been removed.
Files changed:install.php.Security
- Installer:
$alreadyHintrendered unescaped — The "already installed" hint string from the translation file was interpolated raw into thedie()HTML output. Wrapped withhtmlspecialchars().
Files changed:install.php.Improved
Installer:
getTimezonesList()called once per render — The timezone list was built twice during a single GET render (once for the Pro branch, once for the Community branch). Extracted to a single call before the conditional.
Files changed:install.php.🚀 Changelog — Flatboard 5.5.7
Release date: May 4, 2026
Improved
- Tag visibility filter: N+1 → 1 query —
Tag::filterTagsByCategoryAccess()previously calledgetDiscussionsByTag()once per tag (N round-trips to disk/SQL). A newStorageInterface::getTagCategoryMap(array $tagIds)method retrieves all tag→category mappings in a single pass, reducing the cost to one storage call regardless of how many tags are checked.Category::canView()results are memoized within the call to avoid redundant permission lookups.
Files changed:app/Storage/StorageInterface.php,app/Storage/JsonStorage.php,app/Storage/SqliteStorage.php,app/Models/Tag.php. - Tag page & API: count without loading full discussions —
JsonStorage::countDiscussionsByTag()previously calledgetDiscussionsByTag()(loads all discussion objects) just to count them; it now scans only the lightweightdiscussion_tagsindex files.TagApiController::getTotalDiscussions()no longer loads up to 10 000 full discussion objects for pagination; it delegates tocountDiscussionsByTag()instead.TagController::show()replaces its 1 000-discussion ceiling with a targetedgetDiscussionsByTag($id, $perPage * 2, $offset)+countDiscussionsByTag()pair.
Files changed:app/Storage/JsonStorage.php,app/Controllers/Discussion/TagController.php,app/Controllers/Api/TagApiController.php. - Tag page: remove dead
method_existsguard — Themethod_exists($storage, 'getDiscussionTagsBatch')check inTagControllerwas added when only one backend had the method; both backends now implement it, so the fallback loop has been removed.
Files changed:app/Controllers/Discussion/TagController.php. - Profile pages: halve
getPostsByDiscussion()call count —ProfileControllerandUserControllereach made two separate passes over all discussions to collect user posts (for the posts tab) and user posts (for the reactions tab), doubling disk reads. Both have been refactored into a single combined pass, also memoizingCategory::canView()per category within the loop.UserControllernow uses the same batch reaction loading already present inProfileController.
Files changed:app/Controllers/User/ProfileController.php,app/Controllers/User/UserController.php. Remove all dead
method_existsguards — Everymethod_exists($storage, …)check wrapping a method that is declared inStorageInterfacewas always true and its fallback branch unreachable. All such guards and their fallback code have been removed across the codebase. The one exception (getAllPosts, not in the interface) has been kept. Dead private helpersPost::buildThreadsManually(),comparePostsByDate(), andattachChildren()— only ever reachable through the removed fallback — have also been deleted. The fourUserApiControllerfallback methods (getUserDiscussionsFallback,getUserPostsFallback,getUserReactionsFallback,getUserSubscriptionsFallback) are gone.
Files changed:app/Controllers/User/ProfileController.php,app/Controllers/User/UserController.php,app/Controllers/Discussion/DiscussionController.php,app/Controllers/Api/UserApiController.php,app/Services/PostEnricher.php,app/Services/DiscussionEnrichmentService.php,app/Services/SearchService.php,app/Services/UpdateStatsService.php,app/Services/SitemapService.php,app/Models/User.php,app/Models/Post.php,app/Models/Category.php,app/Models/Tag.php,app/Models/Group.php.🚀 Changelog — Flatboard 5.5.6
Release date: May 3, 2026
Security
extract()hardened withEXTR_SKIP— Bothextract()calls inPluginViewController(frontend and named-view routes) and the one inPluginCard::renderAdminView()now pass theEXTR_SKIPflag, preventing plugin hook data from silently overwriting local variables ($plugin_data,$plugin_id, etc.) in the view scope.
Files changed:app/Controllers/Plugin/PluginViewController.php,app/Views/admin/components/PluginCard.php.Improved
Plugin setting descriptions auto-link URLs —
FormFieldHelpernow convertshttp(s)://URLs in field description strings to clickable<a>tags (opens in new tab). All existing plugin settings that already contained provider links (Captcha, Flatbot, MediaHub) benefit immediately without any translation file changes.
Files changed:app/Helpers/FormFieldHelper.php.🚀 Changelog — Flatboard 5.5.5
Release date: May 3, 2026
Fixed (Pro — FlatHome 1.0.13)
- FlatHome — Author avatar removed from CMS page header — The author name and avatar were displayed at the top of every CMS page, which is inappropriate for static pages (About, Contact, etc.). Date and view count are already shown in the banner; the block has been removed from
views/page.php.Added (Pro — FlatHome 1.0.12)
- FlatHome — "Advanced settings" collapsible on settings page — The "Behaviour" and "Sharing" setting groups are now collapsed under an "Advanced settings" toggle on the plugin settings page, reducing the visible option count for new users. The section is automatically hidden when the blog is disabled.
Added (Pro — FlatHome 1.0.11)
- FlatHome — Setup guide card in admin dashboard — A contextual "Getting started" card appears at the top of the FlatHome admin tab when the configuration is incomplete: blog enabled without a category assigned (pointing to the "Blog category" dropdown on the same page), or homepage set to CMS Page with no published pages (with a direct link to create the first page). The card disappears once both conditions are resolved.
FlatHome — Blog settings hidden when blog is disabled — On the plugin settings page, all blog-related option groups (blog, display, behaviour, sharing) and the "Show blog in nav" field are hidden when
blog_enabledis unchecked. Fields remain in the DOM and submit their current values, so no configuration is lost when temporarily disabling the blog. Theblog_enabledtoggle itself is always visible regardless of its declared category inplugin.json.🚀 Changelog — Flatboard 5.5.4
Release date: April 28, 2026
Fixed
- Core — MarkdownHelper: table regex broken by invalid PCRE range in 5.5.3 — The 5.5.3 fix changed the separator-row character class to
[|:- ], but that places-between:(ASCII 58) and` (ASCII 32), forming an invalid descending range. PHP's PCRE engine fails to compile the pattern andpreg_replace_callbackreturnsnull, soMarkdownHelper::parse()returned null for every call, silently breaking all fallback-parser rendering. Corrected by moving-to the front of the class:[-|: ]+. Tables with any number of columns (including all-empty header rows such as| | |) now render correctly. *Files changed:*app/Helpers/MarkdownHelper.php`. - Plugin EasyMDE / TUIEditor — Blank-line injection broke tables (5.5.3 regression) — The 5.5.3 blank-line injection regex
([^\n])\n(\|)matched any non-newline character followed by a|, which meant it also inserted a blank line between a table's header row (ending with|) and its separator row (starting with|). Parsedown then saw the separator as a standalone paragraph rather than the continuation of a table block, silently discarding the entire table. Fixed by narrowing the preceding-character class to[^|\n]so the injection fires only when the line before the|does not itself end with|(i.e. is not already a table row).
Files changed:plugins/EasyMDE/libs/EasyMDEHelper.php,plugins/TUIEditor/libs/TUIEditorHelper.php. Plugin EasyMDE 2.3.7 / TUIEditor 1.3.6 — Tables broken when rows have trailing spaces — The
[^|\n]fix was still bypassed when a table row ended with|(pipe followed by a trailing space or tab): the last character before\nwas a space, so the injection fired between every row, inserting blank lines that caused Parsedown to treat each row as a standalone paragraph and render the table as raw pipe-delimited text. Fixed by stripping trailing spaces and tabs from pipe-terminated lines (/(\|)[ \t]+(?=\n)/m) before the injection step.
Files changed:plugins/EasyMDE/libs/EasyMDEHelper.php,plugins/TUIEditor/libs/TUIEditorHelper.php.🚀 Changelog — Flatboard 5.5.3
Release date: April 27, 2026
Fixed
- Core —
getUserReactions()crashes with "no such column: u.avatar_url" — The SQLite query inSqliteStorage::getUserReactions()selectedu.avatar_urlbut theuserstable schema defines the column asavatar. The query fails on any GDPR data export for a user who has received reactions. Fixed by selectingu.avatarinstead. Note: 5.5.2 fixed the same pattern onr.icon→r.emojiin the same query but missed this second alias.
Files changed:app/Storage/SqliteStorage.php. - Core — MarkdownHelper: multi-column tables not rendered by fallback parser — The separator-row pattern
([-: ]+)only allowed-,:, and spaces. For any table with 2+ columns the separator line contains intermediate|column dividers (e.g.|------|-----|), which caused the regex to never match. Changed to([|:- ]+). Tables with any number of columns now render correctly when neither EasyMDE nor TUIEditor is active.
Files changed:app/Helpers/MarkdownHelper.php. - Core — Rebuild Markdown Cache skipped posts that already had rendered HTML —
MaintenanceController::rebuildMarkdown()only re-rendered posts whosecontent_hashhad changed or whoserendered_htmlwas empty. Posts with an outdated but non-emptyrendered_html(e.g. rendered before a parser fix) were silently skipped. The rebuild now unconditionally re-renders every post, since the point of an explicit rebuild is to apply the current renderer to all content regardless of whether the source changed.
Files changed:app/Controllers/Admin/MaintenanceController.php. Plugin EasyMDE / TUIEditor — Markdown tables not rendered when immediately preceded by a paragraph — Parsedown requires a blank line before a table block; without it the header row (
| Feed | URL |) is absorbed into the preceding paragraph, and when the separator row (|------|-----|) is encountered the column count of the merged paragraph text no longer matches the separator's alignment count, causingblockTable()to return silently. Both helpers already normalised code fences with a blank-line injection (([^\n])\n(\``)); the same pattern is now applied to table openers (([^\n])\n(|)). *Files changed:*plugins/EasyMDE/libs/EasyMDEHelper.php,plugins/TUIEditor/libs/TUIEditorHelper.php`.🚀 Changelog — Flatboard 5.5.2
Release date: April 19, 2026
Fixed
- Core —
getUserReactions()crashes with "no such column: r.icon" — The SQLite query inSqliteStorage::getUserReactions()joined onr.iconbut thereactionstable schema defines the field asemoji, noticon. Changedr.icon as reaction_icontor.emoji as reaction_icon. Affected the GDPR data export for any user who had received reactions.
Files changed:app/Storage/SqliteStorage.php. - Core — GDPR export contained third-party personal data and derived fields — Full audit against GDPR Article 20 (data portability). Fields removed from exports: discussions —
pinned_by,locked_by,solved_by,best_answer_set_by(moderator IDs=third-party data),last_post_user_id,last_post_id(references to other users),post_count(derived counter),mediahub_meta(internal metadata); posts —rendered_html(derived from markdown, was ~53 % of archive size),edited_by(moderator ID present in 117+ posts),content_hash(internal hash); reactions —given_byusername (reactor's personal data, not the data subject's). Status flags (is_pinned,pinned_at,is_locked, …) and aggregate stats (view_count,replies_count) are retained as they describe the subject's own content.
Files changed:app/Services/ExportService.php. - Core — Export metadata
exported_atused server local time instead of UTC —date('c')uses the PHP server timezone, producing e.g.2026-04-20T14:38:01-04:00while the archive filename and README both display UTC. Changed togmdate('Y-m-d\TH:i:s\Z')so the field is always2026-04-20T18:38:01Z.
Files changed:app/Services/ExportService.php. - Core — Export rate-limit logged as ERROR instead of WARNING — The outer
catchinExportService::exportUserData()logged every exception — including the deliberate rate-limitRuntimeException— atLogger::error. Rate-limit hits are expected user behaviour and are now logged atLogger::warning; only genuine failures remain aterrorlevel.
Files changed:app/Services/ExportService.php. - Core — Anonymous visitors tracked on
/favicon.icoand static assets; dead middleware removed —Router::trackVisitor()is the sole visitor-tracking code path (VisitorTrackingMiddlewarewas never attached to any route). The Router only skipped/api/and AJAX requests, so browsers and bots requesting/favicon.icoor other static assets were recorded as active visitors. Fixed by adding the same ignored-paths and ignored-extensions filters. Thevisitor.before_trackhook (used by plugins such as AccountWatcher) is now fired from the Router.VisitorTrackingMiddlewarehas been deleted.
Files changed:app/Core/Router.php,app/Services/AnalyticsService.php(comment only).
Removed:app/Middleware/VisitorTrackingMiddleware.php. - Core — Double toast notification on GDPR export request error — The global
fetchinterceptor intoast.jsalready displays a toast for any response withsuccess: false, butrequestExport()inprofile-manager.jswas also callingshowToast()in itscatchblock, producing two identical error toasts on a single click. Both redundantshowToast()calls (error and success paths) removed; the inline modal message and the global interceptor are sufficient.
Files changed:themes/assets/js/frontend/modules/profile-manager.js. - Core — GDPR data export incomplete and malformed — Multiple issues in
ExportService: (1)getUserPosts()only scanned the user's own discussions, missing all replies posted in other members' discussions — a GDPR violation; fixed by iterating all discussions. (2)notification_preferencesandpreferenceswere exported as JSON-encoded strings embedded inside JSON instead of proper objects. (3) Thetokens/directory was always created empty. (4)getUserReactions()always returned[]despite the storage having a workinggetUserReactions()method; now returns actual reaction data. (5)getUserBookmarks()stub removed from the export payload. (6) The README.txtnow lists every section and item count instead of a single line. (7) Language fallback ingetUserLanguage()was hardcoded to'fr'.
Files changed:app/Services/ExportService.php. - Core — CSRF validation fails for JSON AJAX requests —
Controller::verifyCsrf()only checked$_POSTfields and HTTP headers for the CSRF token. Fetch requests sending JSON bodies (Content-Type: application/json) have no$_POSTdata, and custom headers can be stripped by proxies or Apache+PHP-FPM FastCGI. The controller now also checks the JSON body ($request->json('csrf_token')), and the Reputation recalculate fetch includes the token in the JSON payload. Thesecurity.csrfInvalidtranslation key is now defined in all 5 language error files so users see a readable message instead of the raw key.
Files changed:app/Core/Controller.php,plugins/Reputation/views/dashboard.php,languages/{fr,en,de,pt,zh}/errors.json. - Core — CSRF token not found for AJAX requests on Apache + PHP-FPM —
Request::parseHeaders()usedgetallheaders()when available and fell back to$_SERVER['HTTP_*']only when it was absent. On Apache + PHP-FPM,getallheaders()is present but may omit custom headers (e.g.X-CSRF-Token) that FastCGI doesn't forward, while$_SERVER['HTTP_X_CSRF_TOKEN']— always populated by PHP core — was never consulted. Fixed by always reading$_SERVER['HTTP_*']first and only supplementing withgetallheaders()on top.
Files changed:app/Core/Request.php. - Core —
flock()TypeError on discussion view counter under lock contention — Whenflock(LOCK_EX | LOCK_NB)failed to acquire the lock, the handle was closed but$lockHandlewas not set tonull. The closed resource is still truthy in PHP, so thefinallyblock calledflock()on it, triggering aTypeError. Fixed by setting$lockHandle = nullafterfclose()in the contention path.
Files changed:app/Models/Discussion.php.