🚀 Changelog — Flatboard 5.6.0 "BASTION"

Release date: May 17, 2026

Security

  • SQL injection: SqliteStorage::count() — table and column names not validated — The $table parameter and condition keys were interpolated directly into the SQL query without any validation. $table is 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 $table parameter and interpolated it directly into the INSERT statement. $table is now checked against an explicit whitelist (GENERIC_TABLES); column names from $data are also validated with a regex before use.
    Files changed: app/Storage/SqliteStorage.php.
  • Mass assignment: updateUser() and updateDiscussion() — all columns accepted without filter — Both methods iterated over the caller-supplied $data array and built SET col = :col clauses for every key, including sensitive columns (is_banned, group_id, is_admin, api_token, password_hash). Each method now filters $data against 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 template field of a CMS page (stored in JSON) was used verbatim to construct an include path (modeles/{template}.php). A value like ../../app/Config could reach files outside the plugin directory. The field is now passed through basename() and stripped of any character outside [a-zA-Z0-9_-] before path construction. The same sanitisation is applied to the slug fallback and to the boot-time template detection in FlatHomePlugin.php.
    Files changed: plugins/FlatHome/views/page.php, plugins/FlatHome/FlatHomePlugin.php.
  • buildCategoryFilter() — IDs not validated before use in PDO::quote() — Category IDs passed to the IN (...) clause were quoted via PDO::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 and app/Views (shared by ClassicForum, IPB, NordTheme, bootswatch, default). Translated in all 5 languages via discussion.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 /users now shows the last activity date ("Dernière visite : il y a X") for offline users, using the existing profile.view.lastSeen translation key and DateHelper::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_replies locks a discussion the moment any reply lands; post.edit_requires_no_replies locks 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 in DiscussionController, PostController, and PostPermissionChecker; moderators are exempt. All three flags default to true on fresh installs and after update. New error keys permission.discussionEditRequiresNoReplies and permission.postEditRequiresNoReplies give 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 groupsUpdateController::mergeJsonFile() used deepMergePreserveExisting() to reconcile plugin.json from the archive with the installed version. When the archive was built from the developer's environment, its plugin section contained the developer's own page_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 installed plugin section verbatim after the deep merge: the plugin key 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 discussionsTagApiController::getDiscussions() called validateSlug() which reads $request->get('slug'), but route parameters ({slug} in /api/tags/{slug}/discussions) were never merged into the Request object — only $_GET/$_POST were. The slug was always null, the validator returned 400, and the JS load-more handler converted the HTTP error into a toast. Fixed by adding Request::setRouteParams() and calling it from Router::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 checkUserController::index() had no permission gate: any visitor could reach /users directly via URL regardless of profile.view or presence.view permissions. 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 /users link in header.php was rendered unconditionally, without checking whether the current visitor held profile.view or presence.view. It now applies the same guard as the FlatHome-managed link.
    Files changed: themes/premium/views/layouts/frontend/header.php.
  • FlatHome: /users nav item gated on forum_enabledshow_users_in_nav was only honoured when forum_enabled was 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/usersstrpos($uri, '/users') returned true for /admin/users, causing the users nav link to appear highlighted when browsing the admin panel. The check is now preg_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 \n before a newline. When content is saved with Windows-style \r\n line endings (e.g. pasted from a Windows client or saved by certain editors), the \r before each \n matched [^|\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 existing common.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 calls countAllDiscussionsByTag() 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() and canDelete() 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 to Config::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 viewsshadowbanList() called User::find() in a loop (one query per banned user). premoderationList() called User::find() and Discussion::find() in two separate loops. Both methods now collect all required IDs upfront and load users in a single User::findMany() batch call; discussions are loaded with one getDiscussion() call each after deduplication. A duplicate PermissionHelper::can() check in requireModerationAccess() has also been removed.
    Files changed: plugins/FlatModerationExtend/FlatModerationExtendController.php.
  • FlatModerationExtend: O(n) isAdmin/isModerator calls in pre-moderation notifiernotifyModeratorsPremod() called GroupHelper::isAdmin() and GroupHelper::isModerator() for every user; each call internally re-loaded user and group data. The method now resolves the admin and moderator group IDs once via GroupHelper::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 private getAllPosts() helper loaded all discussions and then called getPostsByDiscussion() for each one (N+1 filesystem or SQL operations). On SQLite, it now executes a single SELECT * FROM posts directly. The JSON-storage fallback retains the loop but avoids the O(n²) array_merge by 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 private getAllNotifications() helper loaded all users and then called getUserNotifications() for each one. On SQLite, it now executes a single SELECT * FROM notifications directly. The JSON-storage fallback similarly avoids array_merge in a loop.
    Files changed: app/Controllers/Admin/UserManagementController.php.
  • Post rendering: serve rendered_html from cache when content hash matchespost-thread.php was re-parsing every post's Markdown on every page view via MarkdownHelper::parse(), even when the stored rendered_html was already up to date. Posts now serve the pre-rendered HTML directly when content_hash matches sha1($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 refresh rendered_html for 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\b pattern in isBot() — 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-extended not matched by isBot()applebot-extended was listed in extractBotName() with its display name but had no corresponding entry in isBot(), so Apple's extended crawler was never classified as a bot. A dedicated /applebot-extended/i pattern has been added before the generic /\bapplebot\b/i entry.
    Files changed: app/Models/Visitor.php.
  • Bot naming: netsystemsresearch not resolved in extractBotName()NetSystemsResearch was recognized as a bot by isBot() but had no entry in extractBotName(), 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 callsisUserLoggedIn() in presence-manager.js and updatePresence() in main.js used document.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-menu and .user-dropdown[data-bs-toggle="dropdown"] referenced by these checks do not exist in the theme HTML. The detection now exclusively checks for #userDropdown and #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(\/|$)/ in toast.js did 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 reads discussionFormConfig.translations.rateLimitMessage, populated server-side via Translator::trans('rateLimit.discussion.cooldown') in create.php. The fallback creation-error message was also hardcoded French; it now reads from config.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 detectionerrorMessage.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 === 429 alone is correct and language-independent.
    Files changed: themes/assets/js/frontend/modules/discussion-form-manager.js.

    Improved

  • Discussion form: missing i18n keys in default themeapp/Views/discussions/create.php (default theme) was missing the rateLimitMessage and createError translation entries added to the premium theme's discussionFormConfig, leaving users on the default theme with hardcoded fallbacks.
    Files changed: app/Views/discussions/create.php.
  • Remove debug console.log statements from production JS and views — Development-only logging (including 100 statements in discussions/show.php and emoji-heavy debug output in infinite-scroll.js) has been cleaned from all frontend modules and view files. Remaining console.log calls 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.json was already present, the installer redirected to the hardcoded path /, which is wrong for subdirectory installations (e.g. /mysite/). The redirect now calls detectBaseUrlDuringInstall() 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: $alreadyHint rendered unescaped — The "already installed" hint string from the translation file was interpolated raw into the die() HTML output. Wrapped with htmlspecialchars().
    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 queryTag::filterTagsByCategoryAccess() previously called getDiscussionsByTag() once per tag (N round-trips to disk/SQL). A new StorageInterface::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 discussionsJsonStorage::countDiscussionsByTag() previously called getDiscussionsByTag() (loads all discussion objects) just to count them; it now scans only the lightweight discussion_tags index files. TagApiController::getTotalDiscussions() no longer loads up to 10 000 full discussion objects for pagination; it delegates to countDiscussionsByTag() instead. TagController::show() replaces its 1 000-discussion ceiling with a targeted getDiscussionsByTag($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_exists guard — The method_exists($storage, 'getDiscussionTagsBatch') check in TagController was 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 countProfileController and UserController each 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 memoizing Category::canView() per category within the loop. UserController now uses the same batch reaction loading already present in ProfileController.
    Files changed: app/Controllers/User/ProfileController.php, app/Controllers/User/UserController.php.
  • Remove all dead method_exists guards — Every method_exists($storage, …) check wrapping a method that is declared in StorageInterface was 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 helpers Post::buildThreadsManually(), comparePostsByDate(), and attachChildren() — only ever reachable through the removed fallback — have also been deleted. The four UserApiController fallback 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 with EXTR_SKIP — Both extract() calls in PluginViewController (frontend and named-view routes) and the one in PluginCard::renderAdminView() now pass the EXTR_SKIP flag, 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 URLsFormFieldHelper now converts http(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_enabled is unchecked. Fields remain in the DOM and submit their current values, so no configuration is lost when temporarily disabling the blog. The blog_enabled toggle itself is always visible regardless of its declared category in plugin.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 \n was 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 in SqliteStorage::getUserReactions() selected u.avatar_url but the users table schema defines the column as avatar. The query fails on any GDPR data export for a user who has received reactions. Fixed by selecting u.avatar instead. Note: 5.5.2 fixed the same pattern on r.iconr.emoji in 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 HTMLMaintenanceController::rebuildMarkdown() only re-rendered posts whose content_hash had changed or whose rendered_html was empty. Posts with an outdated but non-empty rendered_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, causing blockTable() 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 in SqliteStorage::getUserReactions() joined on r.icon but the reactions table schema defines the field as emoji, not icon. Changed r.icon as reaction_icon to r.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: discussionspinned_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); postsrendered_html (derived from markdown, was ~53 % of archive size), edited_by (moderator ID present in 117+ posts), content_hash (internal hash); reactionsgiven_by username (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_at used server local time instead of UTCdate('c') uses the PHP server timezone, producing e.g. 2026-04-20T14:38:01-04:00 while the archive filename and README both display UTC. Changed to gmdate('Y-m-d\TH:i:s\Z') so the field is always 2026-04-20T18:38:01Z.
    Files changed: app/Services/ExportService.php.
  • Core — Export rate-limit logged as ERROR instead of WARNING — The outer catch in ExportService::exportUserData() logged every exception — including the deliberate rate-limit RuntimeException — at Logger::error. Rate-limit hits are expected user behaviour and are now logged at Logger::warning; only genuine failures remain at error level.
    Files changed: app/Services/ExportService.php.
  • Core — Anonymous visitors tracked on /favicon.ico and static assets; dead middleware removedRouter::trackVisitor() is the sole visitor-tracking code path (VisitorTrackingMiddleware was never attached to any route). The Router only skipped /api/ and AJAX requests, so browsers and bots requesting /favicon.ico or other static assets were recorded as active visitors. Fixed by adding the same ignored-paths and ignored-extensions filters. The visitor.before_track hook (used by plugins such as AccountWatcher) is now fired from the Router. VisitorTrackingMiddleware has 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 fetch interceptor in toast.js already displays a toast for any response with success: false, but requestExport() in profile-manager.js was also calling showToast() in its catch block, producing two identical error toasts on a single click. Both redundant showToast() 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_preferences and preferences were exported as JSON-encoded strings embedded inside JSON instead of proper objects. (3) The tokens/ directory was always created empty. (4) getUserReactions() always returned [] despite the storage having a working getUserReactions() method; now returns actual reaction data. (5) getUserBookmarks() stub removed from the export payload. (6) The README .txt now lists every section and item count instead of a single line. (7) Language fallback in getUserLanguage() was hardcoded to 'fr'.
    Files changed: app/Services/ExportService.php.
  • Core — CSRF validation fails for JSON AJAX requestsController::verifyCsrf() only checked $_POST fields and HTTP headers for the CSRF token. Fetch requests sending JSON bodies (Content-Type: application/json) have no $_POST data, 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. The security.csrfInvalid translation 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-FPMRequest::parseHeaders() used getallheaders() 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 with getallheaders() on top.
    Files changed: app/Core/Request.php.
  • Core — flock() TypeError on discussion view counter under lock contention — When flock(LOCK_EX | LOCK_NB) failed to acquire the lock, the handle was closed but $lockHandle was not set to null. The closed resource is still truthy in PHP, so the finally block called flock() on it, triggering a TypeError. Fixed by setting $lockHandle = null after fclose() in the contention path.
    Files changed: app/Models/Discussion.php.