📓 Changelog — Flatboard 5.8.13 — POLARIS
Release date: September 5, 2026
Added
- Backup creation (
/admin/backups) now shows real step-by-step progress with a percentage bar instead of an indefinite spinner for the whole duration of the ZIP build. Both "Create backup" (selective) and "Create complete archive" now go through a new step-basedPOST /admin/backups/stependpoint — session-tracked state, one short HTTP call per category (config/data/uploads/logs/cache/plugins/themes for the selective backup; app/public/languages/vendor/stockage/uploads/plugins/themes/docs & versions for the complete archive) — replacing the old single blocking request (/admin/backups/createand/admin/backups/create-full-archive, removed) that gave no feedback until the entire archive was done, which could look frozen on larger installs (all plugins/themes/vendor included). Extracted the step-wizard UI (percentage bar, step checklist with active/done/error states, a retry-with-backoff fetch helper) into a new reusableStepProgresscomponent — a JS class, a sharedstep-progress.phpview partial and shared CSS — now also powering the local-update wizard on/admin/updates, StorageMigrator's migration wizard, and several other admin plugin pages that had each independently built the same step-tracking UI. Consolidating it removed roughly 9.9 KB of duplicated, inline (non-cacheable) JS/CSS spread across six different admin pages in favor of one ~9 KB external, browser-cacheable component — the real gain isn't that one-off KB difference so much as no longer re-transmitting a slightly different copy of the samemarkStep/setProgress/retry logic inline on every single page load of each of those six screens. Files changed:app/Controllers/Admin/BackupController.php,app/Core/App.php,app/Views/admin/backups.php,app/Views/admin/updates.php,app/Views/admin/partials/step-progress.php,themes/assets/js/admin/components/StepProgress.js,themes/assets/js/admin/modules/backups-management.js,themes/assets/css/admin/components.css,languages/{fr,en,de,pt,zh,pl}/admin.json. - New shared
CheckboxGroupSelectorcomponent (themes/assets/js/shared/CheckboxGroupSelector.js) for the "select all with a live counter" pattern found independently re-implemented, each with slightly different quality, across a batch of admin pages (indeterminate-state calculation, filter-aware select-all, bulk-action button enable/disable). Adopted so far by Logger's per-category event checklist, PrivateMessaging's inbox/sent message lists (previously two byte-for-byte duplicate copies of the same code), and several other admin plugin pages — a few of these gained a correct indeterminate "some selected" checkbox state they never had before. Files changed:plugins/Logger/views/admin.php,plugins/PrivateMessaging/views/{inbox,sent}.php. - Polish (
pl) translations for the IPB and Bootswatch themes — both had shipped with zero Polish coverage sinceplwas added to the project, unlike every other actively-maintained theme (Horizon, default, premium already had it). Fulllangs/pl.jsonadded to both (IPB: forum-icon legend, board stats, admin panel menu; Bootswatch: the whole theme-picker admin page, including all 26 theme names). Files added:themes/IPB/langs/pl.json,themes/bootswatch/langs/pl.json.Fixed
- A step-based backup could return HTTP 500 on its very first step after
init— the selective-backupinitstep opened a freshZipArchiveand closed it immediately without adding anything to it;ZipArchive::close()never actually writes a file to disk when the archive has zero entries, so the following step (config) tried to reopen a ZIP that was never created (ZipArchiveerror 9, file not found). Fixed by writing a throwaway marker entry atinittime (removed again at the finalmanifeststep, in both the selective and complete-archive modes) so the file always exists for later steps to reopen. Also hardened along the way: a step arriving with no archive-in-progress in session (lost/expired session, or a call out of sequence) now returns a clean error instead of a fatal type error, andStepProgress's automatic retry now only applies to genuine network failures — a definitive server response, even an error one, is never retried, since these step endpoints already tear down their own state as soon as they fail, so retrying could only ever mask the real error behind a confusing secondary one. Files changed:app/Controllers/Admin/BackupController.php,themes/assets/js/admin/components/StepProgress.js. - A plugin admin or frontend view naming one of its own template variables
confighad it silently replaced by an unrelated internal object — found live-testing a plugin page while browser-QA'ing this release.PluginViewController::show()(both the plugin-frontend and plugin-admin rendering paths) declared its own local$config = Config::getInstance()for internal language detection, then injected the plugin's hook-provided template variables withextract($viewVars, EXTR_SKIP)— which never overwrites a variable that already exists in scope, so a plugin's own$config(e.g. its settings array) was always discarded in favor of that internal object, with no warning, crashing on the first array access. Renamed the internal variable so it can no longer collide with anything a plugin view defines. Files changed:app/Controllers/Plugin/PluginViewController.php. - The shared alert→toast auto-conversion could silently drop a link inside the alert it was converting —
toast.jsbuilt the toast's message from the alert'stextContent, which discards all markup; an alert with a call-to-action link inside it (e.g. an empty-state "create your first X" message) would still show the right wording once converted, but the link itself became plain, unclickable text with no indication anything was lost. It now clones the alert, removes only its own close button, and keeps the rest of the markup (including links) intact. Found while converting one such alert on an admin plugin page to use the shared toast display instead of a plain static box. Files changed:themes/assets/js/shared/toast.js. - The link-preservation fix above introduced a same-day regression: the discussion page's "replying to [post]" info chip could get silently swallowed into an empty ghost toast, and removed from the page — that chip (
#reply-to-info) is inert JS-controlled UI (an icon plus an empty<span>, populated only once the visitor clicks reply on a specific post), never real alert text, but the oldtextContent-based check happened to treat it as empty and skip it; switching toinnerHTMLmade it non-empty (the icon markup itself), so the auto-conversion started sweeping it into a toast with no visible message and removing the original element — breaking "reply to a specific post" outright (document.getElementById('reply-to-info')returningnullthereafter) on every install using it. Caught from a user report with a screenshot of the empty toast. Fixed intoast.js(an alert with no real text, after stripping tags, is still skipped, same as before) and, as a second, explicit safeguard,#reply-to-infonow opts out viadata-toast="none"in all three views that define it. Files changed:themes/assets/js/shared/toast.js,app/Views/discussions/show.php,themes/{premium,Horizon}/views/discussions/show.php. - Several sections of FlatSEO's admin page silently lost all of their JavaScript on installs using French translations — 18 places (per-page meta, redirections, audit, rank tracker) interpolated a translated string raw into a single-quoted JS string; several of those French translations contain an apostrophe (e.g. "Échec de l'audit"), which closed the string literal early and broke the parsing of that entire inline
<script>block, silently — every handler defined in it (buttons, filters, search) simply did nothing when clicked, with no visible error to the admin. Found browser-testing thewindow.ButtonLoaderadoption below, which surfaced the first occurrence; a full sweep of the file turned up 17 more of the same pattern. All 18 now go throughjson_encode()instead of raw interpolation. Files changed:plugins/FlatSEO/views/admin.php. i18n:auditreported 42 coremain/adminkeys as translated nowhere at all, when they actually were — the 4 themes that use them (premium, Horizon, IPB, ClassicForum for forum-stats/join-us/icon-legend strings undermain; Bootswatch for its whole theme-picker page underadmin) all correctly provide them through their ownlangs/*.json, whichTranslator::loadThemeTranslations()merges into the exact same core domain at runtime for whichever theme is active — the audit'scode_missingcheck compared only against the baselanguages/*/main.json/admin.jsonfiles and had no notion of that merge, so it flagged every one of them as missing in every locale, all false positives. It now also credits a key found in any theme's own translation file for that locale, mirroring the runtime merge — eliminated all 42 false positives while leaving genuine gaps (structural per-locale differences) detected exactly as before. This did surface one real, narrower gap it was masking: 34 of those keys were genuinely untranslated for Polish specifically, because IPB and Bootswatch never hadplsupport at all (see above). Files changed:app/Services/I18nAuditService.php.- Hovering over a user mention/avatar could throw
e.target.closest is not a functionand break the hover-card tooltip for the rest of the page — the delegatedmouseenter/mouseleavelisteners behind it are attached todocument(with thecaptureflag they need, since neither event bubbles) so they see every such event on the page, including the rare case where the browser reportsdocumentitself ase.target(e.g. the mouse leaving/entering the viewport during a navigation) —Documenthas no.closest(), onlyElementdoes. Found testing an unrelated page (the reputation leaderboard) after clicking a username link mid-navigation. Both listeners now bail out immediately unlesse.targetis actually anElement. Files changed:themes/assets/js/frontend/modules/user-tooltip-manager.js.Changed
admin-init.jsno longer logs its startup sequence to the browser console on every admin page load in production — nine unconditionalconsole.log()calls (BASE_URL detection, awindow.url()self-test, readiness events) fired on every single request. Gated behind a newwindow.DEBUGflag, populated from the existing server-sideConfig::get('debug', false)(now also exposed to JS viatranslations.php, next towindow.BASE_URL) — logging comes back automatically when debug mode is turned on. Files changed:themes/assets/js/admin/admin-init.js,app/Views/components/translations.php.- A handful of admin plugin pages (including FlatSEO's audit runner) now use the existing shared
window.ButtonLoadercomponent for their "click button, show spinner, restore on completion" actions, instead of each independently togglingdisabled/innerHTMLby hand — that component (themes/assets/js/shared/button-loader.js) was already loaded on every page but simply wasn't being used by these call sites. No visible change; a couple of edge cases where a failed request used to leave a button stuck mid-spinner are fixed as a side effect. Files changed:plugins/FlatSEO/views/admin.php.📓 Changelog — Flatboard 5.8.12 — POLARIS
Release date: September 5, 2026
Security
- Outgoing SMTP mail never verified the server's TLS certificate by default — including password-reset and email-verification links — found auditing
EmailService.phpwhile checking a vendored library update elsewhere.sendViaSMTP()/sendViaSMTPWithError()setverify_peer/verify_peer_nametofalseandallow_self_signedtotrueunless an admin explicitly added an undocumentedsmtp.verify_ssl: truekey toconfig.jsonby hand — no admin UI exposed it,install.phpnever wrote it, and no doc mentioned it. A network attacker able to intercept or redirect the SMTP connection (DNS spoofing, a compromised network hop) could present any certificate and read every outgoing email, including password-reset and email-verification tokens, undetected. The same file also computed this default two different ways in its diagnostic/error-logging code paths (truethere,falseon the real connection), which could have shown a misleading "you can disable SSL verification" suggestion when it was already disabled. All four call sites now go through one new sharedEmailService::resolveVerifySsl(). New installs (install.php) now writeverify_ssl: trueby default; existing installs keep their current behavior unchanged (no forced reconnection failure on upgrade for a self-signed relay) but the admin dashboard now warns once (same pattern as the existing debug-mode/maintenance-mode notices) whenever SMTP is enabled without certificate verification, and the setting is now a visible, documented checkbox in Admin → Settings → Email instead of a hidden config key. Verified end-to-end against a local test SMTP server (STARTTLS, self-signed certificate):verify_ssl: truecorrectly rejects the untrusted certificate at the TLS handshake, while the inherited default and an explicitfalseboth still deliver a complete, correctly-formatted message — confirming the fix and full backward compatibility. Files changed:app/Services/EmailService.php,app/Controllers/Admin/{ConfigController,DashboardController}.php,app/Views/admin/config.php,install.php,languages/{fr,en,de,pt,zh,pl}/{main,admin}.json.Added
- The
/admin/webhookshelp panel now explains what webhooks are actually for, not just the wire format. The "How webhooks work" card gained a "What webhooks are for" section: a short intro plus seven concrete use cases (posting to a Slack/Discord/Teams channel on new activity; triggering a Zapier/Make/n8n scenario; syncing sign-ups to a CRM or email platform; feeding a dashboard or data warehouse; routing new posts through an anti-spam/toxicity service; replicating content or accounts to another site or search index; running custom logic on your own server), and a closing note that the configured URL must be an HTTP-POST endpoint you host or one an automation tool gives you. Fully translated in all six core locales. Files changed:app/Views/admin/webhooks.php,languages/{fr,en,de,pt,zh,pl}/admin.json.Changed
- Outbound webhooks are now a Pro-edition feature, gated the same way the Analytics dashboard already is.
App\Controllers\Admin\WebhookControllercallsrequirePro()afterrequireAdmin()in all five actions (index,history,save,test,stats), so on the Community edition/admin/webhooks*shows the existingadmin/pro-requiredupgrade page instead of the config screen.WebhookService::triggerInstance()also returns early whenFLATBOARD_PROisn't defined — a safety net so a Pro→Community downgrade with leftoverwebhooks.urlsin config never dispatches. The sidebar link stays visible on both editions (again matching Analytics). The inbound receiverPOST /api/webhooksis a separate developer extension point (config.jsonsecret + IP whitelist, no admin UI) and is deliberately left available on all editions. Docs updated:docs/5-admin-panel.md,docs/13-api.md,docs/21-pro.md(Pro-vs-Community table + a new "Outbound Webhooks" section). Files changed:app/Controllers/Admin/WebhookController.php,app/Services/WebhookService.php,docs/{5-admin-panel,13-api,21-pro}.md. - The "Webhooks" sidebar entry now has its own icon (
fa-satellite-dish) instead of reusingfa-plug, which the "Extensions" entry (and the collapsible "Extensions" section header) already use — the two were visually indistinguishable in the admin sidebar. Changed in all six backend header templates (map entry + hard-coded<i>) and on the/admin/webhookspage title. Files changed:app/Views/admin/webhooks.php,app/Views/layouts/backend/header.php,themes/{premium,Horizon,bootswatch,ClassicForum,IPB}/views/layouts/backend/header.php. - EasyMDE (Community)
2.3.21 → 2.3.22— routine dependency audit: vendored library bumped2.20.0 → 2.21.0(no security fix, just a checklist toolbar button and a list-switching spacing fix; CSS selectors and every toolbar button name this plugin configures verified unchanged before swapping the bundle). Also removed two dead files that had been shipping unused in every archive:dist/codemirror/tablist.jsand the wholedist/codemirror/lang-markdown/directory (raw.tssources, never referenced anywhere and unable to run in a browser as-is). Files changed:plugins/EasyMDE/dist/{easymde.min.js,easymde.min.css}; removedplugins/EasyMDE/dist/codemirror/tablist.js,plugins/EasyMDE/dist/codemirror/lang-markdown/.Fixed
- The Pro-gate page (
admin/pro-required, shown byrequirePro()) was hard-coded to the Analytics feature — title "Analytics" with a chart icon and an "The advanced Analytics page provides…" description — so gating any other feature with it (now Webhooks) displayed the wrong heading and copy on a page whose URL and browser tab still said Webhooks.requirePro()now takes an optional?string $feature('analytics'/'webhooks'), passed through to the view, which picks the heading, icon and description accordingly and falls back to a generic Pro-feature page for any unrecognised value. The Pro benefits list also moves the current page's feature to the top with a "(this page)" marker. New locale keyspanel.pro_gate.{description_webhooks,feature_webhooks,this_page}in all six core locales;feature_analyticslost its baked-in "(this page)" suffix (now added dynamically). Files changed:app/Core/Controller.php,app/Controllers/Admin/{AnalyticsController,WebhookController}.php,app/Views/admin/pro-required.php,languages/{fr,en,de,pt,zh,pl}/admin.json. - The description box on that same Pro-gate page rendered as a top-right toast instead of an inline alert —
shared/toast.jsauto-converts any bare.alert-infoblock. Addeddata-toast="none"so it stays in place inside the card. Files changed:app/Views/admin/pro-required.php. - PrivateMessaging (Pro)
1.1.12 → 1.1.14— a whole cluster of settings silently had no effect, all from the same root cause: the plugin read its config under type-prefixed key names (checkbox_/number_/textarea_/select_) that the generic plugin-settings save path never actually stores under (it always strips that prefix before persisting), so each of these settings was permanently stuck on its hardcoded fallback regardless of what the admin configured. First found on automatic message deletion after N days (never ran,1.1.13), then a follow-up sweep for the same pattern across the rest of the plugin turned up nine more (1.1.14): the blocked-users list (completely unenforced — a "blocked" user could message someone anyway), max message/subject length and max recipients (both server-side validation and the compose-form counters), max messages per user, the date-format display preference, and the typing-indicator toggle. All now read the real config keys; verified against a real (non-production) message store that the cleanup correctly ages out old messages and leaves recent ones alone. Files changed:PrivateMessagingService.php,PrivateMessagingController.php,views/{admin,compose,inbox,sent,view}.php. - The crown icon in the Pro-gate banner's circle was invisible — the circle used
bg-warning bg-opacity-15, but the opacity utility wasn't applied (rendered as a solid orange disc), leaving an orange icon on an orange background. Replaced withbackground:rgba(var(--bs-warning-rgb), .15)— a faint, theme-adaptive disc (picks up the active theme's warning colour) so thetext-warningcrown shows against it. Files changed:app/Views/admin/pro-required.php.Removed
- The
/admin/webhookshelp panel's note about inbound webhooks (added in 5.8.10) was dropped entirely rather than reworded. It describedPOST /api/webhooksand thewebhook.receivedplugin hook, but no bundled plugin does anything visible with them — exactly one has a handler and it only writes a line to the internal system log — so on this page the note read as a feature the admin could use when it is really a developer-only extension point configured inconfig.json. Leaving it out avoids the false impression; the mechanism is still covered where it belongs, indocs/13-api.md. Files changed:app/Views/admin/webhooks.php,languages/{fr,en,de,pt,zh,pl}/admin.json.📓 Changelog — Flatboard 5.8.11 — POLARIS
Release date: September 3, 2026
Added
- The admin sidebar's "Extensions" section (one link per active plugin with an admin page) is now collapsible, with a badge showing the count next to the label — a long list of active plugins (11 on this install) previously just pushed the sidebar footer far down with no way to shrink it and no indication of how many there were. Collapsed state persists across page loads via a
sidebar_extensions_collapsedcookie read server-side before render (same pattern as the existing sidebar-minimize feature,sidebar_minimized), so the section renders already-collapsed on the very first paint — no flash-then-collapse like the minimize feature had before its own fix. Verified live on both sidebar layouts (the<li>/nested-<ul>one used by core/premium/Horizon/bootswatch, and the<div>/cf-admin-nav-listone used by ClassicForum/IPB) in both states, with a real admin session. Files changed:app/Views/layouts/backend/header.php,themes/{premium,Horizon,bootswatch,ClassicForum,IPB}/views/layouts/backend/header.php,themes/assets/js/admin/admin-sidebar.js.📓 Changelog — Flatboard 5.8.10 — POLARIS
Release date: September 3, 2026
Fixed
- Configuring an outbound webhook (URL, secret, events) at
/admin/webhooksnever actually sent anything for real forum activity — reported as "no guidance on how webhooks work", but tracing the delivery path from the admin form down toWebhookService::trigger()found the real problem: nothing called it.DiscussionController,PostController,CategoryController,RegisterControllerandUserManagementControlleronly ever fired the internal plugin hook (Plugin::trigger('discussion.created', ...)etc.) for the 12 supported event types — the queue, retry, HMAC-signing and delivery-history infrastructure behind/admin/webhookswas fully built and already exposed via aWebhookService::trigger()static entry point matching those exact 12 events, just never called from any of those 18 sites. Only the page's "Test" button ever delivered anything, which is why the queue/history/performance panels always read zero. Wired all 18 sites to also callWebhookService::trigger()alongside the existing plugin hook;user.*payloads are stripped ofpassword_hash/two_factor_secret/two_factor_backup_codes/api_tokenfirst (WebhookService::sanitizeUserPayload()) since, unlike a plugin hook, a webhook payload leaves the server. Verified end-to-end against the live local queue (all 12 events enqueue correctly once a webhook is configured; test data cleaned up afterward). Also added an explanation panel to the config page itself (request headers, signature verification, retry policy) and corrected the same information indocs/13-api.mdanddocs/5-admin-panel.md, which had drifted from the real header names (X-Flatboard-Eventdocumented,X-Webhook-Eventactually sent) and event list (documentedreport.created, which doesn't exist; missing 8 of the real 12). Files changed:app/Services/WebhookService.php,app/Controllers/{Discussion/{DiscussionController,PostController},Admin/{CategoryController,UserManagementController,DeleteDiscussionController},Auth/RegisterController}.php,app/Views/admin/webhooks.php,languages/{fr,en,de,pt,zh,pl}/admin.json,docs/{13-api,5-admin-panel}.md.📓 Changelog — Flatboard 5.8.9 — POLARIS
Release date: September 3, 2026
Fixed
i18n:audit's orphan-key count for themain,adminanderrorsdomains was wildly inflated (up to several hundred false positives) because the JS side almost never callswindow.__()— the one call the scanner looks for. Auditing why the report still looked implausible after the last cleanup pass found that most front-end modules instead readwindow.Translations.<domain>...directly by property access (window.Translations?.admin?.webhooks,window.Translations.errors.general.errorOccurred, etc.) — a pattern that bypasseswindow.__()entirely and turned out to be far more common in the existing JS thanwindow.__()itself. Same family of blind spot as the plugin "bulk export" detection added previously, just on the core side this time: any JS file referencingwindow.Translations.<domain>now marks that domain as bulk-exported, and its orphan count is set to null (with the same "export en bloc" badge already used for plugins) instead of showing a misleading number. Does not affect the "missing key" report, which never relied on this detection. Files changed:app/Services/I18nAuditService.php./admin/i18n-audithad no way to reach it without already knowing the URL — its only link, a card on the dashboard's "Maintenance" section, only renders whendebugis enabled in config, while the route itself has no such restriction (justrequireAdmin()). Moved the card out of the debug-gated section into its own always-visible dashboard card, since it's a read-only diagnostic report, not a filesystem-touching tool like its two former neighbors (permission fixing/scanning), which stay debug-gated. Files changed:app/Views/admin/dashboard.php./admin/webhookshad no link anywhere in the admin UI at all — not the sidebar, not the dashboard, not even the breadcrumb table on 4 of the 6 backend header templates (premium,Horizon,ClassicForum,IPBwere missing the entrycore/bootswatchalready had). Added a sidebar entry (and the matching breadcrumb-title entry, alongside/admin/i18n-audit's, which had the same gap on those same 4 themes) to all 6 backend headers. Along the way found that the breadcrumb entry already present oncore/bootswatchpointed at a translation key,panel.menu.webhooks, that was never actually defined in any locale — the page title (and now the new sidebar link) would have shown the raw key. Added the key to all 6 core locales. Files changed:app/Views/layouts/backend/header.php,themes/{premium,Horizon,bootswatch,ClassicForum,IPB}/views/layouts/backend/header.php,languages/{fr,en,de,pt,zh,pl}/admin.json.📓 Changelog — Flatboard 5.8.8 — POLARIS
Release date: September 2, 2026
Security
- The TOTP secret behind two-factor authentication was stored in plain text in the database — found comparing Flatboard's auth stack against another project's during an integration review. Anyone with read access to the database (a backup, a leaked SQLite file, a compromised read replica) could clone any user's 2FA and generate valid codes without ever touching their phone, defeating the whole point of the second factor.
EncryptionHelper(already used for the SMTP password) now takes an optional$contextparameter that derives a distinct key per use case from the same app secret, so a 2FA-context ciphertext doesn't decrypt under the SMTP context or vice versa.two_factor_secretis now encrypted at rest (TwoFactorController::enable()) and decrypted only at the point of use (show(),disable(),LoginController::verify2FA()); existing plaintext secrets keep working transparently (EncryptionHelper::decrypt()already had a legacy-format fallback) so no migration step is needed. - Losing your authenticator device permanently locked you out of your account — 2FA had no backup codes, despite the security docs describing them as if they already existed and a stray
two_factor_backup_codesreference already sitting in the data-export field list with no column, no generator, and no verification behind it. Enabling 2FA now generates 10 single-use backup codes (shown once, hashed at rest — never stored or logged in plaintext), regenerable at any time from the 2FA settings page (itself gated behind a valid TOTP code, since a backup code is a way around the TOTP check). The login 2FA page gained a "use a backup code instead" toggle; entering one consumes it immediately so it can't be reused, and disabling 2FA clears any unused codes along with the secret. Verified against the live local SQLite database: encrypted-secret round-trip, TOTP verification against the decrypted secret, backup-code generation/consumption/rejection-on-reuse, and full disable cleanup all behave correctly. Files changed:app/Helpers/EncryptionHelper.php,app/Helpers/TwoFactorHelper.php,app/Controllers/Auth/TwoFactorController.php,app/Controllers/Auth/LoginController.php,app/Storage/SqliteStorage.php,app/Core/App.php,app/Views/auth/2fa-settings.php,app/Views/auth/2fa.php,languages/{fr,en,de,pt,zh,pl}/auth.json. - A category restricted via
post_groupscould be bypassed by a pre-moderation plugin (FlatModerationExtend) queuing a reply before the permission check ran — found auditing every directDiscussion::create()/Post::create()call site across core and plugins for missing category-permission checks, prompted by a support report of a customer posting into a group-restricted category through an unrelated plugin's endpoint (not a packaged plugin, fixed separately in its own changelog).PostController::store()triggered theview.reply.create.validationplugin hook before checkingCategory::canPost(), not after — so a reply queued for pre-moderation (e.g. from a low-post-count account) never went through that check at submission time, andFlatModerationExtendController::premoderationApprove()only verified the approving moderator's own permission, never whether the original author was allowed to post in the target category. Approving a queued reply could therefore publish it into a category its author was excluded from. The permission check now runs before the hook, andpremoderationApprove()also re-verifiesCategory::canPost()for the author at approval time, in case group membership changed while the reply sat in the queue. The same audit found an installed plugin's AI-assisted reply endpoint checking only the forum-widemoderation.moderatepermission, letting a moderator excluded from a category bypost_groupspost there anyway — fixed the same way; not a packaged plugin, so logged in its own changelog only. Files changed:app/Controllers/Discussion/PostController.php,plugins/FlatModerationExtend/FlatModerationExtendController.php(see the plugin's own changelog for its version bump).Fixed
- Every place a deleted user's posts/discussions are shown ("Utilisateur supprimé") still linked to a profile page — reported with a screenshot showing a
/u/Utilisateur%20supprimélink that 404/403s. The system placeholder account created byUser::getOrCreateDeletedUserId()is intentionally unviewable (inactive, guest group, no real activity), so linking to it was always going to dead-end; the fix removes the link entirely rather than trying to make it resolve.User::isDeletedPlaceholder()is the new single check reused everywhere: the post author name and "edited by" byline in the thread view, the "ban user" menu item (banning a placeholder account made no sense either), the discussion-list author/last-poster names, the rich avatar hover card, and the compact user/visitor preview cards. The same fix was needed in an installed plugin's reputation-points badge next to each post; not a packaged plugin, so logged in its own changelog only. Also fixed a related, separate bug found while auditing this: the sidebar's "newest member" widget could show the deleted-user placeholder itself as the forum's newest member (it's created withemail_verified = 1like a real account, so it passed that widget's only filter) — excluded explicitly now. Files changed:app/Models/User.php,app/Views/components/post-thread.php,app/Views/discussions/_discussion_item.php,app/Views/users/{_user_card_modern,_visitor_card_modern}.php,themes/{premium,Horizon}/views/components/{post-thread,sidebar-stats}.php,themes/{premium,Horizon,IPB,ClassicForum}/views/discussions/_discussion_item.php,themes/assets/js/frontend/modules/user-tooltip-manager.js. i18n:audit's "bulk export" detection (which suppresses the unreliable orphan-key count for a plugin that exports its whole translation file at once) only recognized the one core helper it was told about,PluginHelper::getTranslations('literal-id', ...). Auditing why plugin orphan counts still looked implausibly high after themain/admin/errorscleanup found that roughly a dozen plugins reimplement the same "load the wholelangs/<lang>.jsonfile into a flat array" idea under their own method name (getTranslations(),translator(), ad-hoc inline code) instead of calling the shared helper, or call the shared helper with a class constant (self::PLUGIN_ID) instead of a literal string — none of which the detection recognized. It now also recognizesPluginTranslationLoader::load(...), a non-literal id argument (falls back to the plugin owning the file being scanned), and the underlying'/langs/' . $lang . '.json'path-construction idiom directly, regardless of what method wraps it — bringing correctly-flagged plugins from 17 to 29 out of 37. Files changed:app/Services/I18nAuditService.php.- Around 55 messages in the admin backend — the reports filter/table, the groups and categories drag-and-drop management pages, the reactions table, file-size/type validation, theme activation and settings, the sortable-column screen-reader labels, and four backend sidebar section headings (
ClassicForum/IPBthemes) — showed their raw translation key instead of real text, in every locale. Found byi18n:audit. The oldest instance was a single truncated key,Translator::trans('reports.', [], 'admin'), reused for four different messages on the reports page (filter label, resolved/rejected counts, table caption) — now four distinct keys. Two calls used a key one path segment short of an existing one (common.cancelinstead ofcommon.button.cancel) and, since it starts withcommon., would otherwise have silently resolved to nothing forever (that domain's automaticadmin→mainfallback forcommon.*keys was itself a source of false positives in the audit report — see below). Everything else was simply never translated at all. Files changed:app/Views/admin/{reports,updates,bans,categories,groups,tags,reactions,components/{GroupsTable,UserTableRow},users}.php,app/Controllers/Admin/{PluginSettingsController,ThemeController,CategoryController,MaintenanceController}.php,app/Controllers/Moderation/BanController.php,app/Helpers/FileSettingsValidator.php,themes/{ClassicForum,IPB}/views/layouts/backend/header.php,languages/{fr,en,de,pt,zh,pl}/admin.json. - The
i18n:auditreport'sadmindomain had its own false-positive source, symmetric to the theme-overlay one found earlier:Translator::trans()has a special case where an unresolvedcommon.*key in theadmindomain automatically retries themaindomain before giving up — a real, permanent core behavior, not theme-dependent. The scanner didn't model it, socommon.label.email/common.label.username/common.label.all(which only exist inmain) were reported as missing fromadmineven though they resolve correctly in the live app. Fixed by having the audit service reproduce that exact fallback before diffing, so it no longer needs to be worked around by hand for every futurecommon.*key on this domain. - Around 40 error/validation messages across the app — CSRF failures, ban-suspension details, best-answer moderation, category/group/report "not found" errors, upload validation, rate-limit responses, markdown length limits, and the generic field-validation helper used by several controllers — showed their raw translation key instead of real text, in every locale. All found by
i18n:audit, all in theerrorsdomain, three different root causes: (1) many call sites redundantly repeated the domain name inside the key itself (e.g.errors.upload.fileTooLargeinstead ofupload.fileTooLarge,errors.auth.account.suspension.reasoninstead ofauth.account.suspension.reason) — same mistake as theerrors.general.errorOccurredbug fixed earlier, just not caught in that pass; (2) a few call sites used a key that was simply never defined anywhere (csrf.invalid,auth.required,auth.required.login,validation.username.alreadyTaken,user_not_found,validation.category.notFound,validation.group.notFound,report.notFound) when an equivalent, already-translated key existed under a different name (security.csrfInvalid,auth.loginRequired,validation.username.alreadyUsed,notFound.{user,category,group,report}) — repointed to those instead of adding duplicates; (3) about 18 messages (best-answer validation, category color/icon validation, markdown length limits, a handful of "required" fields, rate-limiting, an update-check failure) had genuinely never been translated at all, and always fell back to whatever English or French string was hardcoded next to the lookup — new keys added across all 6 locales. Also fixed theController::validate()generic field-validation helper to reuse the existingvalidation.rules.*keys with the{length}placeholder they actually expect, instead of passing{min}/{max}(which those templates don't recognize). Files changed:app/Core/Controller.php,app/Middleware/CsrfMiddleware.php,app/Controllers/{Auth/LoginController,Api/{TypingIndicatorController,DiscussionApiController,MarkdownApiController,VersionApiController},Admin/{TagController,CategoryController},Discussion/{TagController,DiscussionController,PostController,SearchController},Moderation/ReportController}.php,app/Services/{DiscussionService,MarkdownParserService}.php,app/Helpers/UserEditValidator.php,app/Views/{components/{attachments,report-modal},users/settings}.php+premium/Horizontheme copies,plugins/SSOProvider/Controllers/SSOProviderController.php,plugins/PrivateMessaging/PrivateMessagingController.php,languages/{fr,en,de,pt,zh,pl}/errors.json. - The
i18n:auditscanner itself mis-flagged double-quoted PHP strings using variable interpolation ("webhook.errors.{$errorCode}") as literal, static keys instead of recognizing them as dynamic — fixed so a"…{$var}…"-style string is now correctly treated the same as a concatenated one (unauditable, needs manual review) rather than reported as a phantom missing key. Files changed:app/Services/I18nAuditService.php. - Three translation keys were truncated with a trailing dot (
common.empty.,discussion.reply.,discussion.search.), found by the newi18n:auditcommand described below. SinceTranslator::trans()never throws and returns the raw key itself when a lookup fails, and a non-empty string is always truthy, every?: 'fallback text'guarded against it was silently dead — the literal, untranslated key text was shown instead of the intended fallback (or, for the category/discussion-name placeholder, instead of any placeholder at all). Same bug family as the previously-fixed'reports.'key.common.empty.(used as a category/discussion-name placeholder) is replaced by a proper new key,common.label.unnamed, except for the one discussion-title case which now reuses the existingdiscussion.view.noTitlekey;discussion.reply.(an aria-label, used in two different places) becomesdiscussion.reply.replyToUseranddiscussion.reply.postNavigation;discussion.search.becomesdiscussion.search.tryDifferentFilters. Files changed:app/Views/{categories/index,discussions/{create,edit,index,tags,search,show},components/{banner,post-thread}}.phpand the same views in thepremium/Horizonthemes,languages/{fr,en,de,pt,zh,pl}/main.json. - Five more dead translation lookups, also found by
i18n:audit:discussion.solved(used as the name of the auto-created "Solved" tag — meaning any install where a discussion was marked solved before this fix already has that tag literally nameddiscussion.solvedin its database, not just a display glitch),post.deleted(a delete-post API response message),presence.subnetVisitorsandcommon.button.copy(two tooltips, shown as their own untranslated key name since neither had a?:fallback at all), and a JS search-results fallback (no_results_found) that was already unreachable code — the lookup right before it (discussion.search.noResults) never fails, so it was removed instead of translated. The tag-name and API-message lookups now point to existing, already-translated keys (discussion.view.solved,discussion.view.postDeleted); the two tooltips get a new key each (presence.subnetVisitors,common.button.copy) across all 6 locales. Files changed:app/Controllers/Discussion/DiscussionController.php,app/Controllers/Api/PostApiController.php,app/Views/users/_visitor_card_modern.php,app/Views/users/settings.php,themes/assets/js/search.js,languages/{fr,en,de,pt,zh,pl}/main.json. - Every attachment upload/download error message (file too large, disallowed extension, dangerous file type, permission denied, etc.) showed its own raw translation key instead of real text, in all 14 error cases and all 6 locales.
AttachmentHelper.phplooked its keys up underattachment.error.*/attachment.downloadNotAllowedMessages.*, while the actual language files (and every other attachment-related call site — the controller, the display views, the JS uploader) usediscussion.attachment.error.*/discussion.attachment.downloadNotAllowedMessages.*. Files changed:app/Helpers/AttachmentHelper.php. - A generic "an error occurred" message, used across ban actions, user management, the notifications page and the reply page's error handling, always showed the raw key
errors.general.errorOccurredinstead of real text. The key that actually exists isgeneral.errorOccurredin theerrorsdomain — every call site had accidentally duplicated the domain name into the key itself (and some also passed the wrong domain,main, on top of that). Files changed:app/Controllers/Moderation/BanController.php,app/Controllers/Admin/UserManagementController.php,app/Views/notifications/index.php,app/Views/discussions/show.php+ thepremium/Horizontheme copies. - The reusable confirmation-modal component's default message, and the data-export feature's success/error messages, had no translation at all (
common.confirm.message,export.success.message,export.error.{rate_limit_exceeded,failed,unauthorized}) — every install always fell back to the hardcoded English/French default text baked into the PHP, never the active locale. The confirm-modal now reuses the existingcommon.confirm.are_you_surekey; the four export keys are new, added to all 6 locales. Files changed:app/Views/components/confirm-modal.php+premium/Horizontheme copies,app/Services/ExportService.php,languages/{fr,en,de,pt,zh,pl}/main.json. - The "back to top" button tooltip and two table column headers (Forum / Last post, Subject / Last post) showed their raw translation key on the
ClassicForumandIPBthemes, which never got these keys added when other themes (Horizon/premiumfor the button, none for the columns) introduced them — new keyscommon.button.backToTop,categories.col.forum,discussion.list.col.subject,discussion.list.col.lastPostadded to all 6 core locales. Files changed:languages/{fr,en,de,pt,zh,pl}/main.json. - The IPB theme's pinned/locked/solved topic-icon tooltips depended on an incomplete theme-level override (the
IPBtheme ships no Polish translation file at all, so a Polish install would silently show English text there) — switched to the equivalent, already fully-translated core keysdiscussion.view.{pinned,locked,solved}instead. Files changed:themes/IPB/views/discussions/_discussion_item.php.Added
- New CLI command
i18n:auditto help catch the class of bug behind thread #177/#178's language pack and the various hardcoded-string reports (see thread #183): it scans everyTranslator::trans()/__()/window.__()call acrossapp/,themes/andplugins/and compares the keys actually used against the real language files, reporting missing keys, unused ("orphan") keys, and structural drift between locales, per core domain / plugin / theme. Previously this kind of coverage check only happened by hand (e.g. the manualpllocale sweeps). A companion admin page at/admin/i18n-auditrenders the same report with per-locale completion badges and expandable key lists, linked from the debug-only "Maintenance" panel on the dashboard. A dynamic key (built from a variable or concatenation) is never counted as missing/orphan — it's listed separately as needing manual review — and a plugin that exports its whole translation file in bulk to JavaScript (PluginHelper::getTranslations()) is flagged rather than reported with a misleading orphan count, since none of its keys are individually visible to a static scan. Files changed:app/Services/I18nAuditService.php,app/Cli/Commands/I18nCommand.php,app/Cli/console.php,app/Controllers/Admin/I18nAuditController.php,app/Views/admin/i18n-audit.php,app/Views/admin/dashboard.php,app/Views/layouts/backend/header.php,app/Core/App.php,languages/{fr,en,de,pt,zh,pl}/admin.json.📓 Changelog — Flatboard 5.8.7 — POLARIS
Release date: August 24, 2026
Security
- A logged-in author could still delete their own reply after
post.delete_time_limitexpired, by using the reply/thread page instead of the API — found in a project-wide sweep for permission checks that diverge between two code paths to the same action, prompted by the discussion-delete bugs below.PostController::delete()(the web route behind the reply/thread UI) had its own inline permission check that only ever testedpost.delete/moderation.moderate, never the time limit — whilePostApiController::delete()correctly delegated to the sharedPostPermissionChecker::canDelete(), which does enforce it.PostController::delete()now delegates to the same service, so both paths apply identical rules. The same project-wide sweep found equivalent declared-but-unenforced permission gaps in a couple of installed plugins (one packaged, logged below; others not packaged, logged in their own changelogs). Files changed:app/Controllers/Discussion/PostController.php. - RSS/Atom feed content could leak session-dependent output into the shared public feed cache.
RssServicebuilds each discussion's feed excerpt by running its Markdown through the samemarkdown.pre_processpipeline used everywhere else, which lets plugins pre-process post content — including, for plugins that render different output per viewer (e.g. content restricted to a specific user group, or a live "current logged-in user" placeholder), whichever PHP session happened to trigger that render. Feed output is then cached for 15 minutes under a key that does not vary by session — only by an optional API token — so if a logged-in staff member (or a piece of content's own author) simply visited/rssor/atomin their browser, their session's view of that content got baked into the cache and served to every subsequent anonymous/unauthorized visitor for up to 15 minutes.MarkdownHelper::parse()now accepts ananonymousflag, forwarded to plugins viamarkdown.pre_process, andRssServicesets it when rendering feed content — any plugin honoring the flag renders its viewer-dependent output as a logged-out visitor instead, closing the leak. Files changed:app/Helpers/MarkdownHelper.php,app/Services/RssService.php.Fixed
- Deleting a discussion's first post left a "ghost" discussion behind — 0 posts, still in the database, and with no way to remove it from the UI — reported on the community forum (flatboard.org/d/193). The "More actions" ⋮ menu, including "Delete discussion", is only rendered as part of the first post's markup (
components/post-thread.php), anddiscussions/show.phpnever includes that component when a discussion has zero posts — so once the first post itself was gone, every path to delete the discussion vanished with it. Nothing stopped this from happening:PostController::delete()had no awareness ofis_first_postand treated the OP like any other post, and the frontend's own button logic only routes to the (permission-aware) "delete discussion" flow when the current user is the discussion's author — a moderator deleting someone else's first post, or an author blocked bydiscussion.delete_time_limit/discussion.delete_requires_no_replies, both fell through to the plain "delete post" button/endpoint instead, which bypassed those discussion-level rules entirely. Fixed the way Discourse/Flarum handle this: deleting the first post of a discussion is deleting the discussion —DiscussionController::delete()'s permission checks and full cleanup (posts, reactions, notifications, reports, subscriptions, tags, read status, views, drafts, category counters) were extracted into a sharedDiscussionController::performDelete(), whichPostController::delete()now delegates to whenever the targeted postis_first_post, instead of performing an ordinary post delete. The frontend'sdeletePost()(core + premium/Horizon theme copies) now also follows aredirectin the response, same asdeleteDiscussion(), instead of only ever removing a single DOM node — otherwise a moderator deleting an OP with replies still visible would see stale reply elements left on screen for a discussion that no longer exists server-side. Verified against the live local SQLite database: a moderator deleting a first post with a reply and a reaction now correctly removes the whole discussion in one step. Files changed:app/Controllers/Discussion/DiscussionController.php,app/Controllers/Discussion/PostController.php,app/Views/discussions/show.php,themes/{premium,Horizon}/views/discussions/show.php. The same gap existed in two more places, found by auditing every code path that deletes a post: the API endpoint (DELETE /api/posts/{id}) had an identical unguarded delete, and the moderation bulk-delete-posts panel let a moderator select and remove a first post along with its replies with no first-post handling at all — both now delegate to the sameperformDelete()when the targeted post is the first one, and the bulk-delete panel's JS follows aredirectin the response instead of always reloading the current (now possibly-deleted) discussion page. Additional files changed:app/Controllers/Api/PostApiController.php,plugins/FlatModerationExtend/FlatModerationExtendController.php,plugins/FlatModerationExtend/assets/js/flat-moderation-extend.js. A follow-up sweep for the same "two paths, one skips the shared cleanup" pattern also found FlatModerationExtend's bulk discussion-delete actions (both the frontend list toolbar and the admin bulk tool) callingDiscussion::delete()directly instead ofperformDelete()— no permission bypass there (both already require moderator/admin), but the same orphaned files and 8 dependent tables the individual-delete path already cleans up. Fixed the same way; see the plugin's own changelog for the version bump. - A reaction from a deleted account displayed as a hardcoded French "Utilisateur", even on non-French installs, and stayed visible indefinitely — reported as: a reaction under a post showing "Utilisateur" as its author. Two separate problems. First,
post_reactionsrows were never cleaned up when a user was removed via three of the four deletion paths:UserManagementController::anonymize()/deleteComplete()only deleted them under aninstanceof SqliteStorageguard (a no-op on JSON-storage installs), the plaindelete()action didn't touch them on JSON storage at all, and theInactiveUserManagerplugin's inactive-account purge (see its own changelog) called the wrong storage method entirely, so its reaction cleanup never actually ran on any backend. Every one of those paths now calls the newStorageInterface::deleteUserPostReactions(string $userId): int, implemented for both SQLite (DELETE FROM post_reactions) and JSON storage (filters the affected user out of eachpost_reactions/{postId}.jsonfile). Second, wherever a reaction's author can no longer be resolved (any remaining orphan, or a race during deletion), the display fallback was a literal'Utilisateur'string instead of a translation — it now resolves through the existingcommon.label.userkey (already translated in all 6 locales) before falling back to the French literal. Files changed:app/Storage/StorageInterface.php,app/Storage/SqliteStorage.php,app/Storage/JsonStorage.php,app/Controllers/Admin/UserManagementController.php,app/Controllers/Social/ReactionController.php,app/Views/components/reaction-picker.php,themes/{premium,Horizon}/views/components/reaction-picker.php. - Deleting a user account (plain "delete" action) left every other piece of their data behind on JSON-storage installs, and crashed outright on SQLite installs the moment the account had ever posted — auditing the reaction fix above surfaced the wider cause.
discussions.user_idandposts.user_idareNOT NULLforeign keys tousers(id)with noON DELETE/ON UPDATEclause (NO ACTION, SQLite's default) andPRAGMA foreign_keys = ONis always active, soSqliteStorage::deleteUser()— which never toucheddiscussions/postsat all — threw an uncaughtFOREIGN KEY constraint failed(surfaced to the admin as a 500) for any target with at least one post or discussion; the "anonymize" action hit the exact same wall, because its own reassignment loop pointeduser_idat the literal string'deleted_user', which was never an actual row inusers(grep confirmed no such account was ever created anywhere). On JSON storage there's no FK enforcement, so the plaindelete()action "succeeded" instead, but silently left every dependent record — tokens, subscriptions, drafts, mentions, visitor rows, discussion views, read status, badges, additional groups, audit logs, notifications, edit history — pointing at a user_id that no longer resolved to anyone. Fixed at the root: a real, lazily-created system account (User::getOrCreateDeletedUserId(), iddeleted_user, deactivated, guest group, unusable random password) now backs every reassignment, so the FK is always satisfied;anonymize()uses it instead of the dead literal; and both storage backends'deleteUser()now reassign the user's discussions/posts to it and cascade every dependent table listed above (mirroring, for JSON, the same table-by-table cleanup SQLite already did for the tables it did handle). Verified against the live local SQLite database: a test account with a discussion, a post and a reaction now deletes cleanly through both the plain-delete and theInactiveUserManagerauto-purge paths, with its content correctly reassigned instead of crashing or orphaning. Files changed:app/Storage/SqliteStorage.php,app/Storage/JsonStorage.php,app/Models/User.php,app/Controllers/Admin/UserManagementController.php,languages/{fr,en,de,pt,zh,pl}/main.json. - Private messaging had four unrelated bugs, reported together with screenshots: TUIEditor didn't load on the compose page (EasyMDE worked fine), a confusing "Public view" admin button, two dashboard warnings popping up as toasts instead of staying inline, and untranslated
%d/%splaceholders on the dashboard. Root causes were all different. TUIEditor's page-detection allow-list checked^/private-messages, but the plugin's real routes are all/messages/*— EasyMDE's list already had the correct^/messagesentry, which is why only one editor "worked." The "Public view" button (shown for any plugin with aviews/frontend.php) just redirected to the viewer's own personal inbox — not a meaningful public page to preview — so the file is removed and the button no longer appears. The two dashboard alerts ("suspicious behavior" list, "large threads slow down the system") are persistent contextual notices, but the site-wide.alert→ toast auto-conversion swept them into transient popups anyway (one screenshot showed them still floating over an unrelated admin page after navigating away); both now opt out viadata-toast="none". Last, five strings (including the hourly-distribution chart tooltip, literally showing "%dh : %d messages") passed positional arrays to a translation helper that only substitutes named{key}placeholders — switched to named placeholders in the code and across all 6 locale files. Files changed:plugins/TUIEditor/TUIEditorPlugin.php,plugins/PrivateMessaging/views/admin.php,plugins/PrivateMessaging/views/compose.php,plugins/PrivateMessaging/PrivateMessagingController.php,plugins/PrivateMessaging/langs/{fr,en,de,pt,zh,pl}.json; removedplugins/PrivateMessaging/views/frontend.php.Changed
- Redesigned the admin Extensions (plugins) list for a calmer, more professional look. Each card previously stacked a solid full-width colored header (green/gray/red), three differently-colored solid metadata badges (version/author/license), and three solid-colored action buttons joined into one Bootstrap
btn-group— up to seven distinct saturated colors per card, repeated across all 36 cards on the page. The header is now a neutral top row with a small pastel status icon and a subtle pill (dot + text) instead of a full color block; a thin left-edge accent (green/gray/red) carries the at-a-glance status instead. The three metadata badges became a single muted text line (v1.2.5 · Author · License). The action row is no longer a joinedbtn-group(whose mixed solid/outline buttons produced a visually broken seam) but three evenly-spaced pill buttons with a small gap; the "configure" action switched frombtn-info(cyan, with no semantic meaning elsewhere in the admin) tobtn-outline-primary, matching the neutral-action convention already used for non-destructive actions on other admin list pages (e.g. tags, webhooks). Verified in both light and dark mode on a live local install. Files changed:app/Views/admin/plugins.php,themes/assets/css/admin/modules/plugins-management.css.📓 Changelog — Flatboard 5.8.6 — POLARIS
Release date: August 15, 2026
Fixed
- @mention autocomplete fired a search request (and surfaced an error toast) on a bare
@, before the user typed any username character —mention-manager.js's trigger condition wassearch.length >= this.minSearchLength || search === '': the|| search === ''clause always evaluated true for an empty query, sominSearchLengthwas never actually enforced and every bare@firedfetchUsers('')after its debounce. The backend's/api/users/searchroute requiresq(required|string|min:1|max:200), so the empty query was rejected with a 400 "The q field is required" — which the app's global fetch-response interceptor (toast.js) surfaces as an error toast for any non-2xx JSON response not on its silent-404/401 allowlist (400 isn't covered). Fixed the JS condition to only search once the minimum length is actually reached, and relaxed the endpoint'sqrule tonullable—User::searchByUsername()already safely returns an empty array for an empty query, so there's no behavior change for legitimate empty-query callers, just no more validation error for what is a normal input state on a live-search-as-you-type field. Files changed:themes/assets/js/frontend/modules/mention-manager.js,app/Controllers/Api/UserApiController.php. - Admin dashboard: collapsing the sidebar menu, then navigating to another page, showed the sidebar expanded for a moment before it collapsed back down — the collapsed state was applied entirely by
admin-sidebar.jsafter the page finished loading (DOMContentLoaded), reading asidebarMinimizedflag fromlocalStorage. Since every admin navigation is a full page reload rather than an SPA transition, the browser always painted the sidebar in its default expanded markup first, then the script ran a moment later and toggled it collapsed — a visible flash on every click. The collapsed state is now also persisted to asidebar_minimizedcookie and read server-side in each theme's backend header, which renders a small inline<style>block (desktop widths only, so it can never affect the mobile off-canvas menu) reproducing the collapsed layout before the page paints;admin-sidebar.jsstill applies the real classes on load and hands off from the pre-render guard, so the sidebar now appears collapsed immediately instead of collapsing after the fact. Five themes (Horizon, IPB, bootswatch, ClassicForum, premium) ship their own copy of the backend header layout instead of falling back to the core one, so the fix was ported to each individually — IPB and ClassicForum also needed a couple of theme-specific selectors (.cf-admin-brand-text,.cf-admin-nav-section-title, ...) added to their pre-render block to match what their real collapsed state hides, and their own50px/!importantsidebar width (instead of core's70px) so the pre-render doesn't itself flash into the real collapsed width a moment later. Verified live end-to-end (server-rendered HTML, live toggle + navigation, mobile off-canvas) on Horizon — the theme actually active on this install. Files changed:app/Views/layouts/backend/header.php,themes/{Horizon,IPB,bootswatch,ClassicForum,premium}/views/layouts/backend/header.php,themes/assets/js/admin/admin-sidebar.js.📓 Changelog — Flatboard 5.8.5 — POLARIS
Release date: August 7, 2026
Security
- Two theme-cache admin endpoints had no permission check at all — found auditing every core
/admin/*controller for method/permission-check coverage.ThemeController::clearCache()(/admin/themes/cache/clear) andgetCacheStats()(/admin/themes/cache/stats) were the only two of the controller's 12 actions missing arequireAdmin()call, unlike every sibling action in the same file — a gap, not an intentionally public route. Any anonymous visitor could purge the entire theme/plugin/core asset cache on demand (forcing a full rebuild on the next hit to every page, a cheap repeatable load vector) and read aggregate cache statistics. Verified on a real install: anonymous requests to both now get a 403/redirect, a real admin session still gets normal results. Files changed:app/Controllers/Admin/ThemeController.php. - A plugin's admin settings view could be reached without any permission check, bypassing the normal admin gate, via the core's generic plugin-view route (
/plugin/{id}/admin) —App\Controllers\Plugin\PluginViewControllerrenders a plugin'sviews/admin.phpdirectly whenever the plugin implements theplugin.view.admin.varshook to supply it live data, but only enforces a permission if the plugin also implements a separateplugin.view.admin.permissionshook — an opt-in only one plugin (SeedForge) used. Auditing every plugin implementing.vars, most hadviews/admin.phpreachable this way with no permission check at all, several exposing real email, group, active status) directly rather than through its normal permission-checked controller; another's exposed audit-log entries; another's listed every translation file across core and plugins (and would have leaked through any other still-unprotected plugin's admin route too, since its data-injection hook never checked which plugin/view triggered it). None allowed a destructive action through this specific path — the actual state-changing routes were separately and correctly permission-checked — but several leaked real, sensitive data to anyone who found the URL. Every affected plugin (packaged and not) now hooksplugin.view.admin.permissions, requiring the same permission its normal controller already does. Verified across all currently-active affected plugins on a real install: anonymous/unprivileged visitors now get 403, admins still get through. Files changed (packaged plugins):plugins/FlatHome/FlatHomePlugin.php,plugins/TUIEditor/TUIEditorPlugin.php,plugins/EasyMDE/EasyMDEPlugin.php,plugins/Logger/LoggerPlugin.php— several more non-packaged plugins received the same fix, logged in their own changelogs.Fixed
nginx.confserved the raw source of any.phpfile it didn't explicitly recognize, instead of executing it or blocking it — including plugin config files containing live API keys (e.g.plugins/ResourceManager/Config/Stripe.php) — the sample config only declaresfastcgi_passfor/public/index.phpand/public/api.php; every other request falls through to the catch-alllocation /block'stry_files $uri ..., which serves an existing file as plain static content when nothing more specific matches. Since nginx has no built-in notion of "this extension means execute as PHP" the way Apache's global handler does, any other.phpfile physically present under the document root — not just inplugins/, anywhereapp|stockage|cli|tests|vendordidn't already cover — was returned as plaintext, secrets included, rather than either running or 404ing. Apache's.htaccessdoesn't have this specific gap (unmatched.phpfiles get executed by its global PHP handler, not dumped as source), which is why this went unnoticed until comparing the two side by side. Added a catch-alllocation ~ \.php$ { deny all; return 404; }, positioned after the two legitimate PHP entry points (nginx matches regexlocationblocks in file order, first match wins, so placement here isn't cosmetic) — in both the HTTP block and the commented-out HTTPS template. Also replaceddocs/3-installation.md's own inline nginx/Apache examples — a separate, incomplete snippet with none of these protections, predating the shipped.htaccess/nginx.conf— with instructions to use the real files instead of hand-rolling a config. Files changed:nginx.conf,docs/3-installation.md.- MediaHub's movie/show card never appeared on blog articles, and plugin badges (e.g. FlatPolls) never appeared on blog listing cards (FlatHome
1.0.23 → 1.0.24) — a blog post is a discussion under the hood, but FlatHome's blog views render it through their own templates instead of the standard discussion views, and never fired theview.discussion.show.before_content/view.discussion.badgeshooks those plugins rely on to inject content/badges. Both blog views now trigger them with the same data shape as their standard-discussion equivalents. Files changed:plugins/FlatHome/views/blog/article.php,plugins/FlatHome/views/blog/index.php. - Group-restricted Shortcodes content rendered the same for every viewer on blog articles/comments and discussion-backed CMS pages, and MediaHub's card was also missing from the latter (FlatHome
1.0.24 → 1.0.25) — same root cause as above, found auditing the rest of FlatHome's views: neither echoed their HTML throughview.post.content.render(which re-checks group-restricted shortcodes per viewer, never cached) nor, for CMS pages backed by a discussion, throughview.discussion.show.before_content. Files changed:plugins/FlatHome/views/blog/article.php,plugins/FlatHome/views/page.php. - Restricting FlatHome's blog to a single category didn't restrict anything (FlatHome
1.0.25 → 1.0.26) —/blog/{slug}and/blog/category/{slug}both resolved discussions/categories with no check against the configuredblog_category(Flatboard has no sub-categories, so nothing outside that one category legitimately belongs in the blog), and the blog's sidebar actively listed and linked to every forum category regardless. Any discussion's full content, and every other category on the site, was reachable through the blog just by knowing its slug. Files changed:plugins/FlatHome/FlatHomeService.php,plugins/FlatHome/FlatHomeBlogController.php.Added
- The root
.htaccessis now self-healing likestockage/.htaccessalready was —App::ensureStorageHtaccess()recreatedstockage/.htaccess(Deny from all) if it went missing, but the root.htaccess— the primary layer blocking direct access to/app/,/stockage/,/cli/,/tests/and.json/.db/.log/.mdfiles viamod_rewrite, with thestockage/one only a fallback for hosts wheremod_rewriteis off — had no equivalent: if it was ever deleted (bad manual edit, a hosting panel wiping root dotfiles, a botched restore), those paths would stay exposed indefinitely with nothing logging it. NewApp::ensureRootHtaccess()runs alongside the existing check and recreates it from the same content Flatboard ships with, logging a warning (or an error if the write itself fails, e.g. read-only root). Files changed:app/Core/App.php.Fixed
- A deleted account's session stayed "authenticated" until it expired (up to 24h), instead of being logged out on its next request —
AuthMiddleware::isAuthenticated()andController::requireAuth()only re-verified that the account still exists whenpermissions_versionwas present in the session. That field is set at login byLoginController, but any other path that establishes a session without going through it (2FA completion, an older session predating the field, a future social/passkey login) skipped the existence check entirely — a deleted account's session then kept sailing through as authenticated, with every downstream permission/group check independently discovering the account was gone and logging its own warning instead of the session ever being invalidated at the source. The existence check now always runs. Files changed:app/Middleware/AuthMiddleware.php,app/Core/Controller.php. - Admin users list: sorting silently dropped the active group filter —
admin/users.php's$sortLinkclosure referenced$filterGroupin its generated query string without capturing it viause(...), so it was alwaysnullinside the closure (Warning: Undefined variable $filterGroup) and thegroupparameter vanished from every sort-column link. Files changed:app/Views/admin/users.php. - Deleting a post crashed with a FOREIGN KEY constraint violation once it had a reaction, a mention, an edit history entry, or a reply quoting it —
SqliteStorage::deletePost()ran a plainDELETE FROM postswith no cleanup of dependent rows.post_reactions,mentions, andedit_historyall referenceposts(id)with noON DELETE CASCADE, andposts.parent_id(self-referencing, used for reply/quote chains) has noON DELETEclause either — so SQLite's FK enforcement (PRAGMA foreign_keys = ON) rejected the delete outright as soon as any of those rows existed, surfacing as an uncaughtPDOExceptionand leaving the post undeleted.post_reactionscleanup used to be handled ad hoc in the controller viaReflectionClassto reachSqliteStorage's private PDO handle — moved intodeletePost()itself (now wrapped in a transaction) alongside the two other tables, plusUPDATE posts SET parent_id = NULL WHERE parent_id = :idto detach quoting replies instead of leaving them referencing a deleted post. Files changed:app/Storage/SqliteStorage.php,app/Controllers/Discussion/PostController.php. - Ban form showed the raw translation key
bans.reason.helpinstead of help text, in every locale —admin/bans.php's ban-reason field usedTranslator::trans('bans.reason.help', [], 'admin') ?: 'fallback text', but that key never existed in any of the 6 language files.Translator::trans()returns the key itself (a non-empty string) when a translation is missing, so the?:fallback was dead code — the exact same pattern already fixed elsewhere for thread #183. Added the missingbans.reason.helpkey to all 6admin.jsonlocale files. Files changed:languages/{fr,en,de,pt,zh,pl}/admin.json. - @mention autocomplete never appeared while typing (TUIEditor
1.3.15 → 1.3.16, EasyMDE2.3.19 → 2.3.20) — the mention picker detected which markdown editor was active by duck-typing aneditorInstance.codemirrorproperty, which only exists on EasyMDE. TUIEditor v3 doesn't use CodeMirror at all — even its "markdown mode" renders into a real contenteditable — so the check always failed and mention detection silently fell back to listening on a hidden<textarea>that never receives real keystrokes.window.markdownEditors[editorId]now exposes a small generic adapter (onCursorActivity(),getCursorContext(),replaceBeforeCursor()) that any markdown editor plugin can implement, so the core mention code no longer special-cases a specific editor. As part of the same fix, the suggestions dropdown now opens directly under the text cursor (Discourse/Flarum-style) instead of always below the whole editor box, and the reply-box mention picker (previously a second, EasyMDE-only implementation duplicated across three view files) now reuses the same generic component as the discussion-creation form. Files changed:themes/assets/js/frontend/modules/mention-manager.js,app/Views/discussions/show.php,themes/{premium,Horizon}/views/discussions/show.php. - "Set as best answer" reported success but never actually marked the reply —
SqliteStorage::updateDiscussion()filters incoming fields through a column whitelist (DISCUSSION_UPDATABLE_COLUMNS) before writing, and that whitelist never includedbest_answer_id,best_answer_set_by, orbest_answer_set_at. On SQLite-backed installs (the active storage backend), theUPDATEquery ran and returned successfully — it just silently dropped those three fields, so the discussion'supdated_atchanged but the best-answer marker never did. The controller compounded this by ignoringDiscussion::setBestAnswer()'s boolean return value and always responding withsuccess: true, so the user got a confirmation toast for a write that had no effect. The three columns are now in the whitelist, and the controller returns a 500 error if the update actually fails instead of assuming success. Files changed:app/Storage/SqliteStorage.php,app/Controllers/Discussion/DiscussionController.php. - Horizon: discussion/category banner lost its category color (Horizon
1.0.4 → 1.0.5) —banner.phpcorrectly computes a category-derived color and injects it as an inline gradient background, same as premium. But Horizon's own CSS flattens the banner into a slim low-key strip on purpose (.flatboard-banner{background:var(--bs-secondary-bg)!important}), and that fixed gray background always won over the inline gradient regardless of category, making every banner look identical.banner.php(Horizon copy only) now also exposes the color as a--banner-colorcustom property, and the flat-strip rule fills its background with it directly (solid, no gradient/texture) instead of a plain gray. First attempt diluted the color into a near-invisible pastel viacolor-mix()and overrode the title/icon/description/breadcrumb text to a dark color, which also left the (still white, un-overridden) breadcrumb label unreadable against the pale fill — dropped both overrides so the base rules' white text applies again, now with proper contrast against the solid category color. Files changed:themes/Horizon/views/components/banner.php,themes/Horizon/assets/css/frontend.css.Added
- Self-service "resend verification email" — when
email_verificationis enabled, an account whose verification email never arrived (SMTP unconfigured and themail()fallback also failing, a common case on shared hosting) used to be stuck permanently: the account exists,email_verifiedstays false, login is blocked with no error recovery, and the only escape hatch was an admin manually flipping the flag in/admin/users. A new/resend-verificationpage lets the user request a new verification email themselves (newEmailVerificationController::resend(), rate-limited to 3/hour like password reset, same generic "email sent" response whether or not the account exists to avoid leaking registered emails). The login page links to it, but only right after a login attempt actually fails because of an unverified email (LoginControllerflashes a dedicatedshow_resend_verificationflag for that one redirect) — not as a permanent link sitting next to "Forgot password" for every visitor regardless of context. New translation keys underemail.verification.resend.*(6 locales), andregister.successVerifyEmailSendFailednow points users to this page instead of just "contact an administrator". Files changed:app/Controllers/Auth/EmailVerificationController.php,app/Controllers/Auth/LoginController.php,app/Core/App.php,app/Core/RateLimiter.php,app/Views/auth/resend-verification.php(new),app/Views/auth/login.php,languages/{fr,en,de,pt,zh,pl}/auth.json. - Admin settings now require a successful test send before "Email verification" can be enabled — matches how Flarum/XenForo gate this (a real "Send Test Email" pass, not just "SMTP fields are filled in"), rather than trusting an unverified config the way Flatboard did before.
EmailService::sendTestEmail()previously refused to run at all unless SMTP was enabled — meaning installs relying on the nativemail()fallback (no SMTP) had no way to confirm email could be sent before flipping the toggle on. It now tests whichever pathsend()actually uses in production: SMTP if configured,mail()otherwise. A successful test (either path) recordsemail_test_verified_at;ConfigController::update()now rejects turningemail_verificationon (400, translated error) unless that flag is set, and any change to the SMTP settings invalidates it again so a stale pass from a different configuration can't be used to justify enabling it later. Also replaced the test-email flow's hardcoded French/English strings ($isFrench ternaries, unavailable in the other 4 locales) with proper translation keys.
Two more bugs found testing this against a real install, both now fixed: (1)admin/config.phpwrapped the "Send Test Email"/"Check DNS" buttons in<?php if ($config['smtp']['enabled']): ?>— a leftover from when testing only made sense with SMTP configured. Since the button now also tests themail()fallback, that condition made it disappear entirely on any install without SMTP enabled, i.e. the one case this feature exists for. The buttons are now unconditional, moved out of the SMTP-only#smtp-configblock so they stay visible regardless of the toggle. (2) The gate's error message said "the button below," but the checkbox lives on the "User Settings" tab while the button is on a separate "Email Settings" tab — now says which tab to go to instead. (3) The error toast for this gate was rendering twice:themes/assets/js/shared/toast.js's globalfetchinterceptor auto-toasts any JSON error response unless the route is in itsmanualErrorRoutesexclusion list, andadmin/config/update/test-email/check-dnsweren't in it — even thoughconfig-management.jsalready displays its own error for all three. Added the three routes to that list. Files changed:app/Services/EmailService.php,app/Controllers/Admin/ConfigController.php,app/Views/admin/config.php,themes/assets/js/shared/toast.js,languages/{fr,en,de,pt,zh,pl}/admin.json. Installer no longer enables "Email verification" by default —
install.phpwroteemail_verification => trueunconditionally into the freshconfig.json, regardless of whether the (optional, unchecked-by-default) SMTP step was filled in or ever tested. Every new install was therefore one unluckymail()fallback away from the exact bug fixed above: new signups silently stuck in the "guest" group with a verification email that never arrives. It now defaults tofalse; the SMTP step shows an info notice explaining that email verification stays off until enabled from Admin → Settings, where the test-send gate above ensures it's only turned on once an email has actually been confirmed to go out. Newform.smtp.verificationNoticekey (6 install locales). Files changed:install.php,languages/{fr,en,de,pt,zh,pl}/install.json.📓 Changelog — Flatboard 5.8.4 — POLARIS
Release date: August 2, 2026
Fixed
- Markdown editor toolbar missing on categorized discussion pages (EasyMDE
2.3.16 → 2.3.17, TUIEditor1.3.13 → 1.3.14) — both editors only load their CSS/JS on pages matched by an internal URL allow-list, to avoid shipping ~200–250 KB of assets on pages that don't need them. That allow-list matched/f/{category}/d/{id}/editbut not the plain/f/{category}/d/{id}show page — which is exactly where the reply form lives. On any install using categorized discussion URLs, the discussion view page silently skipped loading the editor entirely, leaving a bare<textarea>with no toolbar and no console error (nothing failed — nothing was ever requested). Both plugins now match/f/{category}/d/{id}unanchored, covering the show and edit routes with a single pattern. Files changed:plugins/EasyMDE/EasyMDEPlugin.php,plugins/TUIEditor/TUIEditorPlugin.php. - Markdown editor toolbar still missing on subdirectory installs after the fix above (EasyMDE
2.3.18 → 2.3.19, TUIEditor1.3.14 → 1.3.15) — the same URL allow-list matched against the raw, unnormalized$_SERVER['REQUEST_URI']instead of going throughRequest::getUrl(), the only place that strips a configuredbase_urland handles the?url=htaccess fallback. On any install served from a subdirectory (e.g.example.com/forum/), the discussion page's real URI is/forum/d/…, which never matches the^/d/…patterns — so the previous fix never took effect there and no editor CSS/JS was ever requested. Both plugins now buildneedsEditor()'s URI from(new Request())->getUrl(). Files changed:plugins/EasyMDE/EasyMDEPlugin.php,plugins/TUIEditor/TUIEditorPlugin.php. - Same subdirectory-install URL bug found across the codebase, fixed plugin by plugin (Pro plugins so far: FlatHome
1.0.22 → 1.0.23, FlatModerationExtend1.0.13 → 1.0.14, ForumMonitoring1.1.7 → 1.1.8) — auditing every$_SERVER['REQUEST_URI']usage inplugins/andthemes/for the same raw-matching pattern that caused the editor bug above turned up a systemic habit across ~20 files. Highlights: FlatHome'sdetectCurrentTemplate()/setBannerData()never recognized/page/{slug}or/blogroutes on a subdirectory install, so a page's dedicated PHP template silently failed to load (falling back to the default forum view) and blog/CMS banners lost their title; FlatModerationExtend'sisDiscussionListPage()/loadStyles()meant the bulk-moderation toolbar never appeared on the discussion list and its own admin CSS never loaded; ForumMonitoring's admin charts CSS never loaded. Each now builds its URI fromRequest::getUrl(), the only place that strips a configuredbase_url. A dozen more non-packaged plugins had the identical bug (logged individually in their ownCHANGELOG.md); the rest of the codebase (cosmetic nav-highlight/banner-title cases only) is still being worked through. Files changed:plugins/FlatHome/FlatHomePlugin.php,plugins/FlatModerationExtend/FlatModerationExtendPlugin.php,plugins/ForumMonitoring/ForumMonitoringPlugin.php. - Same subdirectory-install URL bug, cosmetic cases: wrong banner title/icon on the Messages, Resources, Notifications and Users pages (PrivateMessaging
1.1.9 → 1.1.10, premium5.2.0 → 5.2.1, Horizon1.0.2 → 1.0.3) — continuing the audit above: these plugins/themes matched anchored URL patterns against the raw$_SERVER['REQUEST_URI']to pick the page banner's title/icon, so on a subdirectory install the banner silently fell back to generic text instead of "Inbox", "Notifications", etc. — cosmetic only, nothing stopped working. Also fixed in ResourceManager (not packaged, logged in its ownCHANGELOG.md). Now built fromRequest::getUrl(). Files changed:plugins/PrivateMessaging/PrivateMessagingPlugin.php,themes/premium/views/components/banner.php,themes/Horizon/views/components/banner.php. - Same subdirectory-install URL bug, last cosmetic case: admin Dashboard nav-link/title never highlighted (premium
5.2.1 → 5.2.2, Horizon1.0.3 → 1.0.4, bootswatch1.0.2 → 1.0.3, ClassicForum1.0.2 → 1.0.3, IPB1.0.6 → 1.0.7) — closing out the audit above: the backend header's admin-root detection (preg_match('#^/admin/?$#', ...)in premium/Horizon/bootswatch,StringHelper::startsWith($uri, '/admin')in ClassicForum/IPB) matched against the raw$_SERVER['REQUEST_URI'], so on a subdirectory install the Dashboard link/title never got its active/root styling (every other admin nav link already used a substringcontains()check and was unaffected). Now built fromRequest::getUrl(). This closes the codebase-wide audit started by the EasyMDE/TUIEditor editor-loading bug (forum thread #191) — every raw-REQUEST_URI-matching instance found acrossplugins/andthemes/has now been fixed. Files changed:themes/{premium,Horizon,bootswatch,ClassicForum,IPB}/views/layouts/backend/header.php. - EasyMDE: stray unconditional debug log spamming
php-errors.log(2.3.17 → 2.3.18) — the admin toolbar-config handler had a leftover rawerror_log()call dumping the full toolbar configuration on every load of the admin settings page, regardless of any error condition. Unlike the app's ownLogger::debug()(gated by the configured log level, written to its own rotateddebug.log), a rawerror_log()writes straight to PHP's error log unconditionally — this buried the real symptom in the logs a user attached while reporting the toolbar bug above. Removed. Audited every other plugin for the same raw-error_log()-as-debug-dump pattern: none found — the one other plugin using rawerror_log()(FlatPolls' SQLite storage) only does so insidecatchblocks for actual database errors, and everything else already goes through the gatedLogger::debug(). Files changed:plugins/EasyMDE/EasyMDEPlugin.php. - Removing an attachment on the post-edit form silently did nothing — the edit-post form is loaded as an HTML fragment via
PostApiController::editForm(), then inserted into the page and re-executed by cloning its<script>tags (a standard trick, sinceinnerHTML-inserted scripts don't run on their own). Two stacked CSP nonce bugs, both needed fixing:PostApiController::editForm()/htmlError()wrote their response with a rawechoinstead of going through the framework'sResponsepipeline, which is where the CSP nonce gets stamped onto<script>tags (Response::send()). Skipping it meant the fragment's inline script (which wires up the attachment-removal click handler, in the sharedcomponents/attachments.php) shipped with no nonce at all. Fixed by routing both responses throughResponse::html().- Even with a nonce added, it was still the wrong one: the nonce is generated fresh per HTTP request, so the AJAX fragment's nonce could never match the nonce already active in the CSP header of the page it gets injected into — the browser checks the script against the document's policy, not the fragment's. The script-recreation code in all three
discussions/show.php(core, premium, Horizon) now copies the current page's own nonce (read off any already-approved<script nonce>element via its.nonceIDL property, which stays readable to same-page scripts even though the reflected attribute is hidden) onto the freshly created script element, so it satisfies the page's actual policy.
Together this is why the first attempt at this fix (shipped, then reported as still broken) still failed: it corrected bug 1 but not bug 2 — the console still showed a CSP violation, just against a nonce that no longer matched. Files changed:app/Controllers/Api/PostApiController.php,app/Views/discussions/show.php,themes/premium/views/discussions/show.php,themes/Horizon/views/discussions/show.php.Changed
- "Back to top" button (premium
5.1.9 → 5.2.0, Horizon1.0.1 → 1.0.2): no longer overlaps the Flatbot chat button, and appears more smoothly — the floating back-to-top button sat at the same bottom-right spot as Flatbot's launcher (both ~24–32px from the corner) and behind it (z-index: 1000vs Flatbot's1040), so the two visually collided. It now sits above Flatbot when that button is present via a CSS:has(.flatbot-button.flatbot-bottom-right)rule (raised tobottom: 6rem), and itsz-indexwas lifted to1041. Also cleaned up: the button had two competing scroll handlers (an inline<script>in the footer toggling Bootstrap's.d-none— which killed the CSS opacity fade — plusux-enhancements.js); the inline script is removed and the single remaining handler toggles the.showclass the theme CSS already animates, giving a proper fade+scale entrance (with aprefers-reduced-motionfallback and a:focus-visiblering for keyboard users). Files changed:themes/{premium,Horizon}/views/layouts/frontend/footer.php,themes/{premium,Horizon}/assets/js/ux-enhancements.js,themes/{premium,Horizon}/assets/css/theme-config.css.Added
- Toast confirmation when removing an attachment from a post/discussion form — clicking the "X" on an attachment only updates the pending form state (it still requires Save/submit to persist, same "replace-with-full-desired-array" model as before); nothing told the user the removal had registered, which read as the button silently doing nothing. A toast now confirms the removal and reminds the user to save (new
discussion.attachment.removedkey, 6 locales). Files changed:app/Views/components/attachments.php,languages/{fr,en,de,pt,zh,pl}/main.json. - Mutually-exclusive plugin groups (
exclusive_groupmanifest key) — a plugin can declare"exclusive_group": "<name>"inplugin.jsonto mean "only one active plugin of this kind at a time". Activating one from/admin/pluginsnow automatically deactivates every other active plugin sharing the same group (writingactive: "0"to their manifests, pulling them fromplugins.enabled, calling theirdeactivate(), and clearing their permissions/asset caches), and the admin is told which plugins were switched off (newplugins.exclusive_deactivatedkey, 6 locales). A boot-time safety net also loads only the first plugin of a given group if two are ever active at once (hand-edited manifest, restored archive), preventing the actual conflict. The two rich-text editors, EasyMDE (Community,2.3.15 → 2.3.16) and TUIEditor (Pro,1.3.12 → 1.3.13), now declareexclusive_group: "editor"— they can no longer both be active and inject competing editors on the compose form. The mechanism is generic: any future editor (or other single-slot plugin type) just declares the same group. Files changed:app/Controllers/Admin/PluginController.php,app/Core/Plugin.php,plugins/EasyMDE/plugin.json,plugins/TUIEditor/plugin.json,languages/{fr,en,de,pt,zh,pl}/admin.json,docs/8-plugins.md.Changed
- French admin wording: "Signaux" → "Signalements" for the reports feature — the French locale translated the moderation reports feature as "Signaux" (signals), a mistranslation; the correct term is "Signalements" (reports/flags). Renamed throughout
languages/fr/admin.json(sidebar menu, page title, dashboard stat, empty-state, confirmation dialogs, toast messages, notification texts — 18 values), and the singular noun "signal" → "signalement" where it meant a report. The already-correct "signalé"/"signalement" strings were left untouched, and other locales (EN "Reports", DE "Meldungen", PT "Denúncias") were already correct. Files changed:languages/fr/admin.json. - Logger (Community) hardening & config cleanup (
1.1.8 → 1.1.10) — two manifest changes: (1) marked non-disablable (cantDisable: "1") as an audit-trail integrity guardrail, so the content-action log at/admin/audit-logscan't be silently turned off from the plugins UI by a co-admin or a compromised account (core file logging and the coreAuditLogpage are independent of this plugin, so nothing is lost when it stays on); (2) removed a duplicate configuration surface — the plugin exposed the same webhook/event settings both through the genericform_configform and its dedicated admin page, with incompatible storage schemas (the generic one couldn't even enable webhooks). The redundantform_configis dropped and the newsettings_urlkey routes the card's gear to the single complete page (/admin/plugins/logger/admin). Files changed:plugins/Logger/plugin.json. Logger (Community) config page: per-event webhook checkboxes now work, plus select-all helpers (
1.1.10 → 1.1.12) — the per-event checkboxes previously had no effect on delivery (sendWebhook()fired for every event once webhooks were enabled); they now correctly gate which events send a webhook, via a shared source-of-truth helper also used to render the form. The config page gained a global select-all/deselect-all pair, a per-category master checkbox (with indeterminate state) on each event group, and an explanatory note under the webhook section describing what the URL does. New locale keys in the 6 languages. Files changed:plugins/Logger/{LoggerPlugin.php,views/admin.php},plugins/Logger/langs/{fr,en,de,pt,zh,pl}.json.📓 Changelog — Flatboard 5.8.3 — POLARIS
Release date: July 30, 2026
Added
- Plugin cards can now link their gear button to a dedicated admin page (
settings_urlmanifest key) — plugins whose entire configuration lives on their own admin page rather than in a"plugin"settings section (StorageMigrator and its/admin/storage-migratorpage being the canonical case) showed no gear button at all on/admin/plugins, leaving no path from the card to their settings. A plugin can now declare"settings_url": "/admin/..."inplugin.json: the card's gear links there directly while the plugin is active (an inactive plugin's routes don't exist, so the key is ignored then and the gear falls back to the generic settings page — shown only when the plugin has generic settings). StorageMigrator (Pro, 1.1.6 → 1.1.7) declares it. The other card buttons on that screenshot-reported card were correct all along: no trash button and a disabled toggle both follow its"cantDisable": "1"declaration. Files changed:app/Views/admin/plugins.php,plugins/StorageMigrator/plugin.json.Fixed
- A stale
plugins.enabledentry inconfig.jsonsilently kept a plugin running while the whole admin UI showed it as inactive — found on a live install where four installed plugins displayed "Inactive" on/admin/pluginsyet had their routes, hooks, and frontend behavior fully active. Root cause: Flatboard had two authorities with opposite priorities — the loader (Plugin::shouldLoadPlugin()) checked the config listplugins.enabledfirst and booted anything listed there regardless ofplugin.json'sactivefield, while the admin list (and the toggle) treatedplugin.jsonactiveas the display truth — so any desync (a hand-edited manifest, an interrupted toggle, a plugin reinstalled withactive: "0"over a still-listed id) ran plugins the admin believed were off.plugin.json'sactivefield is now the single source of truth wherever it exists: the loader obeys it and self-healsplugins.enabledin both directions (adds missing active plugins as before, now also purges stale entries, logged),Plugin::isActive()follows the same priority (with a per-request cache, since it now reads the manifest for non-loaded plugins), andPlugin::activate()/deactivate()write the flag toplugin.jsontoo so no caller can recreate the divergence. The config list keeps deciding only for legacy manifests that have noactivefield, and remains a coherent mirror for its direct consumers (PluginHelper::getEnabledPluginIds(),AssetLoader's cache hash). Verified end-to-end: an artificially staled entry no longer boots the plugin and is purged on the next load, and the admin toggle keeps both stores in sync through full on/off cycles. Files changed:app/Core/Plugin.php. Admin URLs of a disabled plugin returned a bare 404 instead of leading anywhere useful — found while testing a freshly deactivated plugin: opening its dedicated settings page (
/admin/plugins/<slug>/settings, e.g. from browser history, a bookmark, or a sidebar link rendered before deactivation) hit a 404, because an inactive plugin is never loaded, so none of its routes exist. The generic settings page (/admin/plugins/settings?plugin=<id>, the ⚙ button in the plugins list) always worked — the 404 only affected the plugin's own registered routes.Router::handleNotFound()now detects/admin/plugins/<slug>/…URLs whose slug matches an installed-but-disabled plugin (newPlugin::findInactivePluginIdBySlug(), matching plugin id or directory name case-insensitively) and 302-redirects the admin to that plugin's generic settings page with an explanatory flash ("this plugin is disabled — its dedicated pages are only available once activated"). Restricted to logged-in admins so anonymous probing still gets a plain 404 and can't enumerate installed plugins. Files changed:app/Core/Router.php,app/Core/Plugin.php,languages/{fr,en,de,pt,zh,pl}/admin.json(newplugins.disabled_settings_redirectkey).📓 Changelog — Flatboard 5.8.2 — POLARIS
Release date: July 29, 2026
Added
- Horizon theme (1.0.0 → 1.0.1): the sidebar "Tags" list now shows each tag's admin-configured icon and color, matching the swatch already used for the "Categories" list right above it (same 16px rounded-square pattern,
getCategoryTextColor()WCAG contrast helper reused for the icon color) instead of plain text links. Tags already hadicon/colorfields (SQLitetagstable, admin UI at/admin/tags) — the theme just wasn't reading them. Falls back to a neutral grayfas fa-tagicon when a tag has neither set. Files changed:themes/Horizon/views/discussions/index.php,themes/Horizon/views/categories/index.php,themes/Horizon/assets/css/frontend.css(new.hz-tag-swatch),themes/Horizon/theme.json. - Admin dashboard now warns when captcha plugins are misconfigured — two new checks alongside the existing debug-mode/maintenance-mode dashboard notices (
DashboardController::checkAuthPluginConflictsAndNotify(), same one-notification-per-admin-until-read pattern): (1) two captcha plugins active at once — they solve the same anti-bot problem (unlike complementary login-method plugins), so running both just stacks two widgets on every form with no security benefit (Plugin::trigger()runs every registered hook unconditionally with no early exit, so this can't cause a validation bypass — confirmed by reading it — just doubled friction); (2) a captcha plugin enabled without its selected provider's site/secret key configured. Files changed:app/Controllers/Admin/DashboardController.php,languages/{fr,en,de,pt,zh,pl}/main.json(newnotification.types.system.{auth_plugins_conflict,captcha_misconfigured}keys). markdown:rebuildCLI command now auto-applies--forcewhendebugis enabled in config — found while debugging the viewer-dependent rendering fix below (Post::rendered_html): without--force, the command skips any post whosecontent_hashstill matches, even if its cachedrendered_htmlwas generated under old hook logic (e.g. before a plugin update) — the raw content didn't change, but what amarkdown.pre_processhook does with it did, and the hash alone can't detect that. In debug mode this footgun is now avoided automatically, with a message explaining why, instead of requiring the admin to remember to pass--forcethemselves. Files changed:app/Cli/Commands/RebuildMarkdownCommand.php,docs/17-advanced.md(documented the--forceflag, previously missing from the quick-reference list).- New theme: Horizon — a minimalist theme inspired by modern community-forum interfaces, requested on flatboard.org thread #189. Cloned from
premium(its card-based discussion/category layout and sidebar column structure turned out to be the closer structural match once checked against real reference screenshots, rather than ClassicForum's table layout used in an earlier iteration) and restyled: a persistent sidebar with "Categories" (colored square swatches, admin-configured icon kept centered inside rather than dropped — an initial pass removed it entirely to match Discourse's plain-color-square look, which discarded real configured data for no good reason) and "Tags" sections on the discussion-list and category-list pages, category badges on discussion rows switched from filled color pills to a small swatch (icon + color) + plain text, the category-grid page's icon block switched to a bigger rounded color square with the icon centered inside, the swatch's icon color computed per-category via the existing WCAG luminance helper so it stays legible against pale category colors (e.g. the pale-yellow "Off-topic" category), the hero banner flattened from a loud gradient to a slim low-key strip, and a general flattening pass (no box-shadows, no hover-lift transforms, rounded 1px-bordered cards, light navbar instead of a solid saturated fill) — new theme defaults forcard_style/navbar_style/avatar_border_radius/animation_level/enable_gradients/enable_shadows/enable_parallaxset accordingly intheme.json. New indigo/lavender single-accent palette. The discussion/thread page (themes/premium/views/discussions/show.php, including its existing post-navigation scrubber sidebar) is flattened through CSS only — no PHP changes there, so it keeps its existing right-hand actions column rather than the persistent left sidebar. Also fixed, while cloning:views/layouts/backend/footer.phploadedtheme-presets.jsfrom a hardcodedthemes/premium/...path instead of the active theme's own directory. Translated in the 6 core locales. Not yet covered: the persistent sidebar doesn't extend to the thread page, and there's no docked/persistent reply composer or stacked participant avatars in the topic list. Files added:themes/Horizon/**(theme.json, views, langs, assets — cloned and restyled fromthemes/premium). - New
view.profile.coverhook, so a plugin can add cover photos to user profiles without a core schema change — requested on flatboard.org thread #189 ("cover photo for user profiles"). Kept out of core on purpose (stays lightweight; a cover photo is a nice-to-have, and several plugins already manage their own SQLite tables independently of the coreusersschema — a "cover photo" plugin can follow the same pattern instead of requiring aSCHEMA_VERSIONbump for everyone).users/profile.phpis a single core file with no per-theme copies, so the hook (and its minimal.profile-cover-wrapCSS scaffold, which pulls the card body up so the avatar overlaps the bottom edge of an injected cover image) automatically applies across all themes — no theme-by-theme changes needed. No core feature uses this hook yet; it's purely an extension point. Documented in the hooks reference (98 hooks total, up from 97). Files changed:app/Views/users/profile.php,docs/8-plugins.md,docs/20-development.md. - Images and videos can now be uploaded as post attachments (separate from inline-embedded images), and the general "Approved attachments" allowlist supports them — requested on flatboard.org thread #189. Flatboard already had a full per-post attachment system distinct from inline markdown images (dedicated upload path, download UI, permissions), but its master allowlist (
FileSettingsValidator::ALLOWED_EXTENSIONS['attachments']) only contained document/archive extensions, so admins couldn't enable image or video types for attachments even though the same extensions were already allowed for inline images/avatars. Addedjpg/jpeg/png/gif/webpandmp4/webm/movto that allowlist, added the three new video types (with icon/MIME metadata) toPermissionController::getFileTypes(), and added the corresponding checkboxes to the admin "Médias & Pièces jointes" panel. While fixing this, foundAttachmentHelper's own (separate, broader) extension allowlist was missingmdeven though the admin panel already allowed enabling it as an attachment type — a pre-existing inconsistency between the two lists — somdwas added there too. Files changed:app/Helpers/FileSettingsValidator.php,app/Helpers/AttachmentHelper.php,app/Controllers/Admin/PermissionController.php,app/Views/admin/permissions.php.Fixed
Post::rendered_htmlcaching silently defeated any plugin logic that depends on the current viewer, not just whoever last saved the post — surfaced via a plugin's per-group content visibility (see that plugin's own changelog for the user-facing symptom). The cached HTML persisted at post create/edit time (app/Views/components/post-thread.phpand itspremium/Horizontheme copies echo it verbatim whenevercontent_hashstill matches) is reused for every future viewer, butMarkdownHelper::parse()'smarkdown.pre_processhook — the extension point plugins use to transform content before it's parsed — ran unconditionally as part of that one-time cached render, with no way for a hook to know its output was about to be frozen and shared.MarkdownHelper::parse()now takes a$cacheableparameter (the four post/discussion create+edit call sites, plus theRebuildMarkdownCommandCLI rebuild, passtrue), passed through to hooks via the pre-process payload so a plugin can opt out of running when it will be cached and instead reprocess live on every display via the newview.post.content.renderhook (fired from all threepost-thread.phpcopies, cache hit or miss, with the post'sauthor_idin the payload). Purely viewer-independentmarkdown.pre_processhooks (e.g. a gallery-block conversion) are unaffected and keep running as part of the cached render.
A first pass only updated the four normal post/discussion create+edit call sites and missed three more places that persistrendered_htmlthe same way — found after a production re-test still showed nothing for Premium-group members, because the admin panel's own rebuild button turned out to be a second, entirely separate implementation of the CLI rebuild command (MaintenanceController::rebuildMarkdown(), notRebuildMarkdownCommand), so fixing the CLI command didn't cover it. AllMarkdownHelper::parse()call sites that write torendered_htmlwere re-audited and now consistently pass$cacheable: true: the admin "Rebuild Markdown Cache" button (MaintenanceController::rebuildMarkdown()), the public REST API's discussion-creation endpoint (DiscussionApiController::create()), and FlatModerationExtend's pre-moderation approval flow (FlatModerationExtendController— approving a queued post or discussion converts it into a realPost/Discussionrecord, which also pre-renders and caches its HTML). Files changed:app/Helpers/MarkdownHelper.php,app/Controllers/Discussion/{PostController,DiscussionController}.php,app/Controllers/Admin/MaintenanceController.php,app/Controllers/Api/DiscussionApiController.php,app/Cli/Commands/RebuildMarkdownCommand.php,app/Views/components/post-thread.php,themes/{premium,Horizon}/views/components/post-thread.php,plugins/FlatModerationExtend/FlatModerationExtendController.php. Newview.post.content.renderhook documented indocs/8-plugins.md/docs/20-development.md(hook count 99→100).- Attachments larger than 10MB were rejected at post-submission time even when the admin-configured max size was set higher — the actual upload endpoint (
UploadController→UploadService::upload()) correctly readuploads.attachments.max_sizefrom config, but the second validation pass that runs when the post is saved (AttachmentHelper::validateAttachments(), called fromPostController::store()) checked the attachment size against a hardcodedDEFAULT_MAX_SIZEconstant (10MB) instead of the same config value — so a file that uploaded successfully under a higher configured limit (the admin panel allows up to 100MB) would still fail with a spurious "file too large" error when the post was submitted. Below 10MB the bug was invisible since the redundant check never triggered. Now readsuploads.attachments.max_sizefrom config, falling back to the 10MB constant only if unset. Files changed:app/Helpers/AttachmentHelper.php. - Admin panel unreachable (redirected to homepage) on a subdomain when already logged in on a parent/sibling domain sharing the same hosting — reported by a user logged into
flatboard.orgwho couldn't reach/adminonv5.flatboard.org(a subdomain), but could in a private-browsing window.Session::configureSecureSession()never setsession.nameorsession.cookie_domainexplicitly, so both fell back to the hosting's PHP defaults (PHPSESSID, and potentially a sharedsession.cookie_domainacross subdomains) — a same-named session cookie from the parent domain's own (unrelated) session got sent to the subdomain and read instead of the subdomain's own session, soController::requireAuth()found nouser_idand redirected out of/admin. Now always forces a Flatboard-specific cookie name (flatboard_session) and a host-onlycookie_domain, regardless of hosting-level session ini defaults. Files changed:app/Core/Session.php. Note: this rotates the session cookie name, so all existing sessions are invalidated once after upgrading. - Avatar tooltip (detailed mode) showed raw Unix timestamps instead of formatted dates —
AvatarHelper::render()'sdetailedmode concatenated$user['created_at']/last_activitystraight into the tooltip text (e.g. "Dernière visite: 1785318830") instead of formatting them, unlikeprofile.phpandcomponents/post-thread.php's own tooltip-building code, which already useddate('d/m/Y', …)andDateHelper::ago(...). Checked the two themes with their ownavatar.phpcopies (premium,Horizon) — both already formatted these dates correctly, so only the shared core helper (used bydefault,ClassicForum,IPB,NordTheme,terminal,bootswatch) was affected. Files changed:app/Helpers/AvatarHelper.php. - Profile settings page: the "Avatar" tab literally showed "Avatar de {username}" instead of the actual username —
Translator::trans('common.label.avatar')was called without theusernamereplacement parameter the string requires. Files changed:app/Views/users/settings.php. - Installer success screen contradicted itself about
install.php— when the installer successfully auto-deleted itself after setup, the success page still showed a second warning box telling the admin to "delete or rename install.php" right below the box confirming it had already been deleted automatically.install.php's success-screen markup rendered thesuccess.securityNotewarning unconditionally, outside theif (!empty($installerRemoved))/elsebranch that already chooses between the "removed automatically" and "removal failed, do it manually" messages.securityNotenow only renders in theelsebranch, alongsideinstallerManual, where it's actually relevant. Files changed:install.php. - StorageMigrator (Pro)
1.1.6— failed migrations showed "Échec de la migration : Array" instead of the real error, and every admin-facing message in the controller was hardcoded French — reported on flatboard.org thread #190.StorageMigratorController::step()built the failure message byimplode()-ing$report['errors']directly, but each entry pushed byJsonToSqliteMigration/SqliteToJsonMigrationis an associative array (type/entity_id/username-or-name/message), never a string — PHP silently casts it to the literal string"Array"instead of erroring, so the actual exception message (e.g. a SQLite constraint violation) never reached the admin. Error entries are now mapped to"{message} ({context})"before being imploded. Verified end-to-end with a forced-failure migration run: old code reproduced the exact reported"Array; Array"output, new code surfaces the real constraint message. While fixing this, an audit of the rest of the controller found every other admin-facing string hardcoded in French too (unknown step,invalid target type,storage already using X,insufficient disk space,pdo_sqlite missing,SQLite requires Pro,invalid storage type,migration successful/failed, the generic exception wrapper, plus the two CSRF-error responses) — all now routed throughTranslator::trans()(newstoragemigratordomain keys added to the 6 core locales; the two CSRF messages reuse the existingerrors.security.csrfInvalidkey). Files changed:plugins/StorageMigrator/StorageMigratorController.php,plugins/StorageMigrator/langs/{fr,en,de,pt,zh,pl}.json. - Generic server errors and validation failures always showed a French message, regardless of the visitor's language — reported on flatboard.org thread #189.
App\Core\ErrorHandler::sendErrorResponse()is the top-level catch-all for any uncaught exception or fatal error; it hardcoded'Une erreur est survenue'for the AJAX/API JSON response and'Une erreur est survenue. Veuillez réessayer plus tard.'for the HTML fallback, overridingResponse::serverError()'s own translated default (errors.general.errorOccurred/errors.http.500.message) instead of using it. Separately,App\Core\Validator— the framework's Laravel-style field validator, used across search, tagging, markdown preview and discussion/post APIs — had every one of its ~33 default rule messages (required,email,min,max,between,confirmed,alpha,uuid, …) hardcoded in French and never routed throughTranslator, so any validation error on those endpoints was French-only for every visitor. Both now go throughTranslator::trans(); a newvalidation.rules.*block (33 keys) was added to theerrorsdomain in the 6 core locales, with each message keeping a literal{field}placeholder soValidator::addError()'s existing custom-attribute-name substitution still works. Files changed:app/Core/ErrorHandler.php,app/Core/Validator.php,languages/{fr,en,de,pt,zh,pl}/errors.json. - Admin "Check DNS" panel showed a generic "Invalid response from server" error instead of the real cause — reported on flatboard.org thread #188. Every
App\Helpers\DnsHelperlookup method (checkSPF,checkDKIM,checkDMARC,checkMX) only caught\Exception, not\Throwable; on a host wheredns_get_record()is disabled (e.g. viadisable_functions) or otherwise unavailable, PHP raises a fatal\Errorthat went uncaught, producing a raw PHP error output instead of JSON — which the frontend (config-management.js) then failed toJSON.parse(), surfacing the generic fallback message instead of anything actionable. All four methods now catch\Throwable, andConfigController::checkDnsRecords()additionally wraps the whole DNS check in atry/catch(\Throwable)as a second line of defense, always returning a proper JSONsuccess:falseresponse with a clear message instead of letting any unexpected error escape as non-JSON output. Both new server-side error messages go throughTranslator::trans()(settings.email.dns.domainNotFound,settings.email.dns.unavailable, new keys added to the 6 core locales) rather than being hardcoded. While fixing this, all ofDnsHelper's pre-existing per-record diagnostic messages (SPF/DKIM/DMARC/MX "not found"/"invalid format"/"fetch failed" and the exception-message wrapper) were also hardcoded in French regardless of locale — these were displayed as-is in the admin DNS results panel for every language. They now go throughTranslator::trans()too (settings.email.dns.results.*, 8 new keys added to the 6 core locales). Files changed:app/Helpers/DnsHelper.php,app/Controllers/Admin/ConfigController.php,languages/{fr,en,de,pt,zh,pl}/admin.json. - Registration always showed "check your email" even when the verification email failed to send — reported alongside thread #188 (same user, missing verification emails).
RegisterController::register()calledEmailService::sendVerification()and discarded its boolean return value, so the success flash message was shown unconditionally regardless of whether the email actually went out — send failures were only ever visible in the server logs, never to the admin or the new member. The controller now checks the return value: on failure it logs a warning (Verification email failed to send during registration) and shows a newregister.successVerifyEmailSendFailedwarning message ("account created, but we couldn't send the verification email — contact an administrator") instead of the misleading success message. New translation key added to the 6 core locales (fr,en,de,pt,zh,pl). Files changed:app/Controllers/Auth/RegisterController.php,languages/{fr,en,de,pt,zh,pl}/auth.json. - Header notification bell disappeared as soon as notifications were read, instead of only when they were deleted —
loadNotificationsUnified()(themes/assets/js/main.js) andupdateNotificationList()(themes/assets/js/frontend/modules/notification-manager.js) both toggled the bell wrapper's visibility off the unread count/list, so marking a notification (or all of them) as read made the bell vanish even though notifications still existed. The bell now toggles on the total notification count (data.notifications.length) instead, so it only disappears once every notification has actually been deleted; the unread badge/counter (#notification-count) keeps reflecting the unread count as before. A second, related bug in the same dropdown:updateNotificationList()only ever rendered unread notifications (.filter(n => !n.read)), so once everything was marked read the dropdown wrongly showed "No notifications" even though read notifications still existed (and were visible on the/notificationspage) — the item renderer already supported a distinct read/unread style, that code path was just unreachable. The dropdown now lists the 5 most recent notifications regardless of read state. Shared JS, so both fixes apply to every theme (default, premium, ClassicForum, IPB, NordTheme, terminal, bootswatch). Files changed:themes/assets/js/main.js,themes/assets/js/frontend/modules/notification-manager.js. - Post-quote hover preview showed the raw
@"username"#postIdcitation marker as visible text — hovering the small quote-reply pill ("↩ Username") on a post that itself replies to another post displayed the internal citation syntax (e.g.Nucl3arTurtl3) glued to the start of the excerpt, instead of just the reply's actual text. The marker is prepended to a post's raw stored content client-side when using the reply/quote button (discussions/show.php'sreplyToPost()), and is normally consumed byMarkdownHelper::parse()when the post body itself is rendered — but the hover-preview API (/api/posts/{id}?preview=true, backed byPostPreviewGenerator) builds its excerpt straight from the raw, unrendered content viastrip_tags(), which doesn't know about this custom syntax.createExcerpt()now strips the citation marker before generating the excerpt. Files changed:app/Services/PostPreviewGenerator.php. - Category permissions page: "select all" toggle and group-selection toasts always shown in French regardless of locale — reported on flatboard.org thread #183.
CategoryMessage.getTranslation()incategories-management.jslooks up keys directly inwindow.Translations, which is namespaced by domain (admin,errors, …) — but several call sites on the category permissions tab (view/post/create groups selectors) passed bare keys with no domain prefix (categories.groups.select_all,categories.message.all_groups_selected/deselected), so the lookup always missed and the hardcoded French default was shown no matter the site or user language. Same root cause for the category-form validation messages (validation.required,validation.maxLength,validation.invalid.color/icon) and the delete-confirmation dialog title, which called a non-existentadmin.categories.title.delete(categories.titleis a plain string, not an object). All call sites now use fully domain-qualified keys; four new keys were added to theadmindomain (categories.message.delete_error,categories.confirm.delete_title,categories.validation.{name_required,name_max_length,color_invalid,icon_invalid}) in the 6 core locales. Files changed:themes/assets/js/admin/modules/categories-management.js,languages/{fr,en,de,pt,zh,pl}/admin.json. - The same two hardcoded-fallback bugs found and fixed across the rest of the admin panel — an audit of every admin JS module (prompted by the categories.js fix above) found the identical two root causes repeated in 9 more modules and a shared component: (1) a local
getTranslation()/UIHelpers.trans()call missing itsadmin./errors.domain prefix, and (2) a<feature>.title.<action>(or similar) lookup where<feature>.titleis actually a plain string leaf in every locale, not an object — both always silently miss and fall back to the hardcoded (mostly French) literal, regardless of locale. Fixed:groups-management.js— delete-confirmation dialog title.bans-management.js— the client-side ban-form validator, the bulk-delete button label, and the delete/revoke confirmation dialogs and titles (~18 call sites).user-edit-admin.js— username/email/group live-validation messages were reading a nonexistentwindow.Translations.validationroot instead ofwindow.Translations.errors.validation; the save-button's "Saving…"/"Save" states were also reading a nonexistentadmin.users.saving/saveinstead of the sharedadmin.common.status.saving/admin.common.action.save.users-management.js— the "anonymize user" confirmation dialog title.themes-management.js— the "activate theme" confirmation title, and the three "clear cache" (all/themes/plugins) confirmation titles (now reusing the existingclear_all/clear_themes/clear_pluginslabels instead of a brokentitle.*lookup).theme-config-management.js— the settings-saved toast was reading the wrong key path entirely (themes.settings.savedinstead of the realthemes.config.settings_saved); the delete-logo dialog title had no key at all.reports-management.js— the delete/purge confirmation dialog titles.reactions-management.jsandDragDropManager.js(shared component, used by any admin list with drag-to-reorder) — the client-side form-validation message and the reorder success/error toasts.config-management.js— the DNS-check and settings-save error messages had no corresponding keys at all.backups-management.js— the restore/delete/create-full-archive confirmation dialog titles.confirm-modal.js(shared component) — its default confirmation message had no translation key (low-impact: every current caller supplies its own message).
New keys were added to theadmindomain (bans.{validation.reason_required,message.auto_ip_banned,action.delete_selected,confirm.delete_title,confirm.revoke_title,ip.known_ips,ip.last_ip,ip.registration_ip},groups.confirm.delete_title,users.confirm.anonymize_title,themes.confirm.activate_title,themes.config.delete_logo_title,reports.confirm.delete_title,common.message.{validation_error,order_updated,order_error},settings.email.dns.error,settings.save_error,backups.{restore.title,delete.title}), to theerrorsdomain (validation.username.{tooShort,tooLong},validation.required.group,general.validationError), and to themaindomain (common.confirm.are_you_sure) — all in the 6 core locales. Files changed:themes/assets/js/admin/modules/{groups-management,bans-management,user-edit-admin,users-management,themes-management,theme-config-management,reports-management,reactions-management,config-management,backups-management}.js,themes/assets/js/admin/components/{DragDropManager,confirm-modal}.js,languages/{fr,en,de,pt,zh,pl}/{admin,errors,main}.json.
- Same bug pattern found and fixed on the public-facing (non-admin) side — extending the audit above to the frontend JS turned up more of the same two root causes (missing domain prefix; wrong or nonexistent key path), plus a few translations objects that were referenced by JS but never actually populated by any PHP view:
search.js— the navbar quick-search dropdown's "Searching…", "Search error", result-type labels (Discussion/post/User) and "No title" fallback all used bare, nonexistent keys; fixed to reuse the correct existingdiscussion.search.*/common.label.*keys.user-tooltip-manager.js— the hover-card shown over usernames (Discussions/Replies counts, Joined/Last seen, Signature, View profile) readwindow.Translations.main.Xinstead of the real nested paths (common.label.*,profile.view.*).infinite-scroll.js— the "Load More" button label (9 call sites across the discussion list, category page, and post pagination) read a nonexistentmain.load_more; now uses the existingcommon.button.loadMore.notification-manager.js— the notification bell's relative timestamps ("2 minutes ago", "3 days ago"…) looked upago_seconds/ago_minutes/etc., which don't exist anywhere; the exact same relative-time strings already exist server-side forDateHelper::relative()(common.time.agoSeconds/agoMinutes/…, pipe-delimited singular|plural) — the JS now reuses those, so client- and server-rendered timestamps are guaranteed to read identically.toast.js— the global fetch-error interceptor'sgetTranslation()helper did a flat lookup (Translations[domain][key]) instead of a dotted-path walk, so all 8 of its HTTP-status/network-error toasts (400/401/403/404/429/500 + network error) always fell back to a hardcoded French string, regardless of locale; helper rewritten to walk the key path like the rest of the codebase, and its keys corrected to the real paths (http.400.title,general.forbiddenShort,network.error, etc.).attachments-manager.jsanddraft-manager.js— both readwindow.Flatboard.translations.{attachments,draft}, a namespace no PHP view has ever populated, so every upload-validation/draft-status message was always the hardcoded literal; both now callwindow.__()directly against the realdiscussion.attachment.*/discussion.draft.*keys (draft-manager.js is currently dead code —DraftManageris never instantiated — so this is a latent fix, not a live one).auth-2fa-manager.js— its generic 2FA-toggle handler expectedenabled/disabledtranslation keys, but the only place that ever populates this object (2fa-settings.php) providesenabledSuccess/disabledSuccess; aligned the key names (this code path has no matching DOM element anywhere yet, so currently latent as well).
No new locale keys were needed for any of the above — every fix reuses a translation that already existed under its correct path. Files changed:themes/assets/js/search.js,themes/assets/js/frontend/modules/{user-tooltip-manager,notification-manager,attachments-manager,draft-manager,auth-2fa-manager}.js,themes/assets/js/frontend/components/infinite-scroll.js,themes/assets/js/shared/toast.js.
- Logger
1.1.8, PrivateMessaging1.1.9, FlatHome1.0.22— PHP-sideTranslator::trans()calls using the wrong domain or a key that doesn't exist — extending the same key-lookup audit to direct PHP translation calls (not just JS) found the identical failure mode server-side. BecauseTranslator::trans()returns the raw key string itself on a miss (not an empty value), aTranslator::trans(...) ?: 'fallback text'pattern built on a bad key is dead code — the literal untranslated key was shown on screen instead of any real text, in every locale:- Logger —
LoggerAdminControllerused the coreadmindomain with alogger.-prefixed key for 6 settings/webhook-test messages, instead of the plugin's ownloggerdomain with its real flat key names; a 7th call (webhook_url_invalid) had no matching translation anywhere at all and was added to the 6 locales. - PrivateMessaging — the inbox's online/offline status tooltip (
aria-label/title, 4 call sites) looked up bareonline/offlinekeys in the coremaindomain; neither exists there (nor with no fallback at all, so the literal word was shown verbatim). Now uses the plugin's own existinglabel.online/label.offlinekeys. - FlatHome — the contact-form rate-limit error looked up a
rate_limit_exceededkey that doesn't exist anywhere; switched to the existing coreerrors.rateLimit.title.
Files changed:plugins/Logger/LoggerAdminController.php,plugins/Logger/langs/{fr,en,de,pt,zh,pl}.json,plugins/PrivateMessaging/views/view.php,plugins/FlatHome/FlatHomePageController.php.Changed
- Logger —
Reaction picker: Facebook-style popup instead of a modal — the "React" button under a post used to open a centered Bootstrap modal, backed by ~150 lines of defensive JS working around Bootstrap modal quirks (stray backdrops, manual ESC/outside-click handling,
hidden.bs.modal/show.bs.modalcleanup). It's now a small popup anchored to the button: icon-only circular buttons (name shown on hover via nativetitle, no permanent label text) laid out in a small fixed-width grid that wraps to additional rows — every reaction is always fully visible with no hidden/hover-only content. A first pass used Bootstrap's own Dropdown component, but that gets visually clipped whenever a reaction-picker's post card is directly followed by another post: every post is its own.card.discussionstacking context (z-index: 1), so a later card in the DOM always paints over anything an earlier card's dropdown overflows into — the exact reason the old modal needed to be moved todocument.bodyin the first place. Fixed by adopting the same escape-hatch already used elsewhere in this codebase for an identical problem (the "+N tags" overflow popup inmain.js): on open, the popup is detached todocument.body, positioned withposition: fixedfrom the button'sgetBoundingClientRect()(flips above/right if it would overflow the viewport), and restored to its original spot on close/outside-click/Escape/scroll/resize. The reaction-toggle logic (fetch, badge-row refresh, active-state update) was consolidated into a single implementation inreaction-manager.js(previously near-duplicated between the component's inline script and the module, for the modal-reinitialization case), used both on first render and after dynamically-loaded content (infinite scroll, etc.). The CSS for this popup (.reaction-dropdown-menu,.reaction-picker-circle) was rolled out to every theme that ships its own frontend styling, adapted to each theme's own visual language (square corners on IPB/terminal to match their blocky look, rounded circles elsewhere) — this was not optional cosmetic polish:.reaction-dropdown-menuneeds its owndisplay: nonedefault now that it no longer piggybacks on Bootstrap's.dropdown-menuclass, so any theme missing this rule would have shown the popup permanently inline on every post.bootswatchin particular never loads afrontend.cssof its own (onlyfrontend-bootswatch-minimal.css), so it never had any reaction-specific CSS at all before and needed the full rule set added fresh. Files changed:app/Views/components/reaction-picker.php,themes/premium/views/components/reaction-picker.php,themes/assets/js/frontend/modules/reaction-manager.js,themes/{default,terminal}/assets/css/{frontend,frontend.dev}.css,themes/{premium,IPB,ClassicForum,NordTheme}/assets/css/frontend.css,themes/bootswatch/assets/css/frontend-bootswatch-minimal.css.📓 Changelog — Flatboard 5.8.1 — POLARIS
Release date: July 21, 2026
UI & Responsive
- Mobile ergonomics audit — sidebar-before-content, redundant login prompt, and undersized tap targets fixed across every theme — a full UX/mobile pass (Chromium at 360/768/1440 px, live DOM measurements of touch-target size and color contrast) turned up several ergonomics issues affecting the two highest-traffic page types on every theme:
- Sidebar rendered before the discussion list on mobile and tablet —
/discussionsand category pages stack the sidebar column (Forums / Tags / category list / Statistics, ~1500 px tall) above the actual discussion list below thelg(992 px) breakpoint, on every phone and every portrait tablet, because the two Bootstrap grid columns had no responsiveorderset (source order=sidebar first). Content now renders first belowlgviaorder-1/order-2(order-lg-*restores the desktop layout). Core view (default/NordTheme/terminal/bootswatch) and the premium theme's own copy, bothdiscussions/index.phpandcategories/index.php. - Discussion page: duplicate login prompt, sort control buried after every post — a guest visitor saw the dismissible guest-CTA banner (register/login) immediately followed by a second, near-identical "Log in to reply" button from the actions sidebar; that same sidebar (containing the reply/subscribe buttons and the reply-sort dropdown) rendered after the entire post list on mobile, so the sort control was only reachable once every reply had already been read in the default order. The redundant button is removed (the guest banner already carries that call to action), and the actions sidebar now renders before the post list on mobile via responsive
order(desktop layout unchanged). Corediscussions/show.php(cascades to default/NordTheme/terminal/bootswatch/ClassicForum/IPB) and the premium theme's own copy. - Touch targets under the 44×44 px minimum (Apple HIG / WCAG 2.5.5 AAA) — the mobile navbar hamburger measured 38×38 px and the login/register submit buttons ~40 px tall. Both raised to a 44 px minimum:
.navbar-togglerin the core header (default/NordTheme/terminal) and its premium/bootswatch/ClassicForum copies (IPB has no navbar-toggler — unaffected), and the submit buttons inauth/{login,login-modal,register}.php(shared by all seven themes). The same fix was applied to a plugin's OAuth-completion form — logged in that plugin's own changelog. - Discussion-post avatars: screen readers heard "Avatar" for every single poster —
common.label.avatarwas passed a{username}parameter that the translation string never used (it was the literal word "Avatar" in all 6 locales), so the interpolation silently dropped and the alt text never identified who the avatar belonged to. The 6 locale files now read"Avatar of {username}"(translated equivalents), completing the interpolation already wired inpost-thread.php. - Premium theme: mobile stat labels ("views" / "replies") at 9.6 px — raised to ~11.5 px for comfortable reading; the compact mobile layout is unchanged otherwise.
Premium bumped to5.1.9, bootswatch to1.0.2, ClassicForum to1.0.2.
Files changed:app/Views/{discussions/index,categories/index,discussions/show,components/post-thread,layouts/frontend/header,auth/login,auth/login-modal,auth/register}.php,themes/premium/views/{discussions/index,categories/index,discussions/show,components/post-thread,layouts/frontend/header}.php,themes/premium/assets/css/frontend.css,themes/{bootswatch,ClassicForum}/views/layouts/frontend/header.php,languages/{fr,en,de,pt,zh,pl}/main.json.Changed
- Sidebar rendered before the discussion list on mobile and tablet —
Discussion banner: tags now sit next to the category badge, with their real color and icon; the duplicate tag row under the first post is removed — the same tag set used to render twice on a discussion page: once as plain outlined pills in the hero banner, once again in color under the first post's content. The banner now reuses the exact colored-pill rendering (per-tag background color, WCAG-contrast text, icon) placed right after the category badge, and the second, now-redundant copy under the post is removed. Core
components/banner.php(cascades to default/NordTheme/terminal/bootswatch/ClassicForum/IPB) +components/post-thread.php, and the premium theme's own copies of both.
Files changed:app/Views/components/{banner,post-thread}.php,themes/premium/views/components/{banner,post-thread}.php.