📓 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-based POST /admin/backups/step endpoint — 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/create and /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 reusable StepProgress component — a JS class, a shared step-progress.php view 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 same markStep/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 CheckboxGroupSelector component (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 since pl was added to the project, unlike every other actively-maintained theme (Horizon, default, premium already had it). Full langs/pl.json added 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-backup init step opened a fresh ZipArchive and 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 (ZipArchive error 9, file not found). Fixed by writing a throwaway marker entry at init time (removed again at the final manifest step, 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, and StepProgress'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 config had 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 with extract($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 convertingtoast.js built the toast's message from the alert's textContent, 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 old textContent-based check happened to treat it as empty and skip it; switching to innerHTML made 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') returning null thereafter) on every install using it. Caught from a user report with a screenshot of the empty toast. Fixed in toast.js (an alert with no real text, after stripping tags, is still skipped, same as before) and, as a second, explicit safeguard, #reply-to-info now opts out via data-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 the window.ButtonLoader adoption below, which surfaced the first occurrence; a full sweep of the file turned up 17 more of the same pattern. All 18 now go through json_encode() instead of raw interpolation. Files changed: plugins/FlatSEO/views/admin.php.
  • i18n:audit reported 42 core main/admin keys 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 under main; Bootswatch for its whole theme-picker page under admin) all correctly provide them through their own langs/*.json, which Translator::loadThemeTranslations() merges into the exact same core domain at runtime for whichever theme is active — the audit's code_missing check compared only against the base languages/*/main.json/admin.json files 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 had pl support at all (see above). Files changed: app/Services/I18nAuditService.php.
  • Hovering over a user mention/avatar could throw e.target.closest is not a function and break the hover-card tooltip for the rest of the page — the delegated mouseenter/mouseleave listeners behind it are attached to document (with the capture flag they need, since neither event bubbles) so they see every such event on the page, including the rare case where the browser reports document itself as e.target (e.g. the mouse leaving/entering the viewport during a navigation) — Document has no .closest(), only Element does. Found testing an unrelated page (the reputation leaderboard) after clicking a username link mid-navigation. Both listeners now bail out immediately unless e.target is actually an Element. Files changed: themes/assets/js/frontend/modules/user-tooltip-manager.js.

    Changed

  • admin-init.js no longer logs its startup sequence to the browser console on every admin page load in production — nine unconditional console.log() calls (BASE_URL detection, a window.url() self-test, readiness events) fired on every single request. Gated behind a new window.DEBUG flag, populated from the existing server-side Config::get('debug', false) (now also exposed to JS via translations.php, next to window.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.ButtonLoader component for their "click button, show spinner, restore on completion" actions, instead of each independently toggling disabled/innerHTML by 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.php while checking a vendored library update elsewhere. sendViaSMTP()/sendViaSMTPWithError() set verify_peer/verify_peer_name to false and allow_self_signed to true unless an admin explicitly added an undocumented smtp.verify_ssl: true key to config.json by hand — no admin UI exposed it, install.php never 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 (true there, false on 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 shared EmailService::resolveVerifySsl(). New installs (install.php) now write verify_ssl: true by 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: true correctly rejects the untrusted certificate at the TLS handshake, while the inherited default and an explicit false both 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/webhooks help 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\WebhookController calls requirePro() after requireAdmin() in all five actions (index, history, save, test, stats), so on the Community edition /admin/webhooks* shows the existing admin/pro-required upgrade page instead of the config screen. WebhookService::triggerInstance() also returns early when FLATBOARD_PRO isn't defined — a safety net so a Pro→Community downgrade with leftover webhooks.urls in config never dispatches. The sidebar link stays visible on both editions (again matching Analytics). The inbound receiver POST /api/webhooks is a separate developer extension point (config.json secret + 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 reusing fa-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/webhooks page 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 bumped 2.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.js and the whole dist/codemirror/lang-markdown/ directory (raw .ts sources, never referenced anywhere and unable to run in a browser as-is). Files changed: plugins/EasyMDE/dist/{easymde.min.js,easymde.min.css}; removed plugins/EasyMDE/dist/codemirror/tablist.js, plugins/EasyMDE/dist/codemirror/lang-markdown/.

    Fixed

  • The Pro-gate page (admin/pro-required, shown by requirePro()) 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 keys panel.pro_gate.{description_webhooks,feature_webhooks,this_page} in all six core locales; feature_analytics lost 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 alertshared/toast.js auto-converts any bare .alert-info block. Added data-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 with background:rgba(var(--bs-warning-rgb), .15) — a faint, theme-adaptive disc (picks up the active theme's warning colour) so the text-warning crown shows against it. Files changed: app/Views/admin/pro-required.php.

    Removed

  • The /admin/webhooks help panel's note about inbound webhooks (added in 5.8.10) was dropped entirely rather than reworded. It described POST /api/webhooks and the webhook.received plugin 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 in config.json. Leaving it out avoids the false impression; the mechanism is still covered where it belongs, in docs/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_collapsed cookie 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-list one 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/webhooks never 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 to WebhookService::trigger() found the real problem: nothing called it. DiscussionController, PostController, CategoryController, RegisterController and UserManagementController only 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/webhooks was fully built and already exposed via a WebhookService::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 call WebhookService::trigger() alongside the existing plugin hook; user.* payloads are stripped of password_hash/two_factor_secret/two_factor_backup_codes/api_token first (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 in docs/13-api.md and docs/5-admin-panel.md, which had drifted from the real header names (X-Flatboard-Event documented, X-Webhook-Event actually sent) and event list (documented report.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 the main, admin and errors domains was wildly inflated (up to several hundred false positives) because the JS side almost never calls window.__() — 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 read window.Translations.<domain>... directly by property access (window.Translations?.admin?.webhooks, window.Translations.errors.general.errorOccurred, etc.) — a pattern that bypasses window.__() entirely and turned out to be far more common in the existing JS than window.__() itself. Same family of blind spot as the plugin "bulk export" detection added previously, just on the core side this time: any JS file referencing window.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-audit had no way to reach it without already knowing the URL — its only link, a card on the dashboard's "Maintenance" section, only renders when debug is enabled in config, while the route itself has no such restriction (just requireAdmin()). 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/webhooks had 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, IPB were missing the entry core/bootswatch already 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 on core/bootswatch pointed 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 $context parameter 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_secret is 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_codes reference 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_groups could be bypassed by a pre-moderation plugin (FlatModerationExtend) queuing a reply before the permission check ran — found auditing every direct Discussion::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 the view.reply.create.validation plugin hook before checking Category::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, and FlatModerationExtendController::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, and premoderationApprove() also re-verifies Category::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-wide moderation.moderate permission, letting a moderator excluded from a category by post_groups post 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 by User::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 with email_verified = 1 like 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 the main/admin/errors cleanup found that roughly a dozen plugins reimplement the same "load the whole langs/<lang>.json file 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 recognizes PluginTranslationLoader::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/IPB themes) — showed their raw translation key instead of real text, in every locale. Found by i18n: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.cancel instead of common.button.cancel) and, since it starts with common., would otherwise have silently resolved to nothing forever (that domain's automatic adminmain fallback for common.* 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:audit report's admin domain had its own false-positive source, symmetric to the theme-overlay one found earlier: Translator::trans() has a special case where an unresolved common.* key in the admin domain automatically retries the main domain before giving up — a real, permanent core behavior, not theme-dependent. The scanner didn't model it, so common.label.email/common.label.username/common.label.all (which only exist in main) were reported as missing from admin even 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 future common.* 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 the errors domain, three different root causes: (1) many call sites redundantly repeated the domain name inside the key itself (e.g. errors.upload.fileTooLarge instead of upload.fileTooLarge, errors.auth.account.suspension.reason instead of auth.account.suspension.reason) — same mistake as the errors.general.errorOccurred bug 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 the Controller::validate() generic field-validation helper to reuse the existing validation.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/Horizon theme copies, plugins/SSOProvider/Controllers/SSOProviderController.php, plugins/PrivateMessaging/PrivateMessagingController.php, languages/{fr,en,de,pt,zh,pl}/errors.json.
  • The i18n:audit scanner 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 new i18n:audit command described below. Since Translator::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 existing discussion.view.noTitle key; discussion.reply. (an aria-label, used in two different places) becomes discussion.reply.replyToUser and discussion.reply.postNavigation; discussion.search. becomes discussion.search.tryDifferentFilters. Files changed: app/Views/{categories/index,discussions/{create,edit,index,tags,search,show},components/{banner,post-thread}}.php and the same views in the premium/Horizon themes, 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 named discussion.solved in its database, not just a display glitch), post.deleted (a delete-post API response message), presence.subnetVisitors and common.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.php looked its keys up under attachment.error.*/attachment.downloadNotAllowedMessages.*, while the actual language files (and every other attachment-related call site — the controller, the display views, the JS uploader) use discussion.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.errorOccurred instead of real text. The key that actually exists is general.errorOccurred in the errors domain — 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 + the premium/Horizon theme 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 existing common.confirm.are_you_sure key; the four export keys are new, added to all 6 locales. Files changed: app/Views/components/confirm-modal.php + premium/Horizon theme 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 ClassicForum and IPB themes, which never got these keys added when other themes (Horizon/premium for the button, none for the columns) introduced them — new keys common.button.backToTop, categories.col.forum, discussion.list.col.subject, discussion.list.col.lastPost added 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 IPB theme 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 keys discussion.view.{pinned,locked,solved} instead. Files changed: themes/IPB/views/discussions/_discussion_item.php.

    Added

  • New CLI command i18n:audit to help catch the class of bug behind thread #177/#178's language pack and the various hardcoded-string reports (see thread #183): it scans every Translator::trans()/__()/window.__() call across app/, themes/ and plugins/ 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 manual pl locale sweeps). A companion admin page at /admin/i18n-audit renders 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_limit expired, 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 tested post.delete/moderation.moderate, never the time limit — while PostApiController::delete() correctly delegated to the shared PostPermissionChecker::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. RssService builds each discussion's feed excerpt by running its Markdown through the same markdown.pre_process pipeline 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 /rss or /atom in 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 an anonymous flag, forwarded to plugins via markdown.pre_process, and RssService sets 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), and discussions/show.php never 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 of is_first_post and 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 by discussion.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 shared DiscussionController::performDelete(), which PostController::delete() now delegates to whenever the targeted post is_first_post, instead of performing an ordinary post delete. The frontend's deletePost() (core + premium/Horizon theme copies) now also follows a redirect in the response, same as deleteDiscussion(), 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 same performDelete() when the targeted post is the first one, and the bulk-delete panel's JS follows a redirect in 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) calling Discussion::delete() directly instead of performDelete() — 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_reactions rows were never cleaned up when a user was removed via three of the four deletion paths: UserManagementController::anonymize()/deleteComplete() only deleted them under an instanceof SqliteStorage guard (a no-op on JSON-storage installs), the plain delete() action didn't touch them on JSON storage at all, and the InactiveUserManager plugin'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 new StorageInterface::deleteUserPostReactions(string $userId): int, implemented for both SQLite (DELETE FROM post_reactions) and JSON storage (filters the affected user out of each post_reactions/{postId}.json file). 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 existing common.label.user key (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_id and posts.user_id are NOT NULL foreign keys to users(id) with no ON DELETE/ON UPDATE clause (NO ACTION, SQLite's default) and PRAGMA foreign_keys = ON is always active, so SqliteStorage::deleteUser() — which never touched discussions/posts at all — threw an uncaught FOREIGN 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 pointed user_id at the literal string 'deleted_user', which was never an actual row in users (grep confirmed no such account was ever created anywhere). On JSON storage there's no FK enforcement, so the plain delete() 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(), id deleted_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 the InactiveUserManager auto-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/%s placeholders 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 ^/messages entry, which is why only one editor "worked." The "Public view" button (shown for any plugin with a views/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 via data-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; removed plugins/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 joined btn-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 from btn-info (cyan, with no semantic meaning elsewhere in the admin) to btn-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 charactermention-manager.js's trigger condition was search.length >= this.minSearchLength || search === '': the || search === '' clause always evaluated true for an empty query, so minSearchLength was never actually enforced and every bare @ fired fetchUsers('') after its debounce. The backend's /api/users/search route requires q (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's q rule to nullableUser::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.js after the page finished loading (DOMContentLoaded), reading a sidebarMinimized flag from localStorage. 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 a sidebar_minimized cookie 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.js still 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 own 50px/!important sidebar width (instead of core's 70px) 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) and getCacheStats() (/admin/themes/cache/stats) were the only two of the controller's 12 actions missing a requireAdmin() 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\PluginViewController renders a plugin's views/admin.php directly whenever the plugin implements the plugin.view.admin.vars hook to supply it live data, but only enforces a permission if the plugin also implements a separate plugin.view.admin.permissions hook — an opt-in only one plugin (SeedForge) used. Auditing every plugin implementing .vars, most had views/admin.php reachable 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 hooks plugin.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.conf served the raw source of any .php file 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 declares fastcgi_pass for /public/index.php and /public/api.php; every other request falls through to the catch-all location / block's try_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 .php file physically present under the document root — not just in plugins/, anywhere app|stockage|cli|tests|vendor didn't already cover — was returned as plaintext, secrets included, rather than either running or 404ing. Apache's .htaccess doesn't have this specific gap (unmatched .php files 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-all location ~ \.php$ { deny all; return 404; }, positioned after the two legitimate PHP entry points (nginx matches regex location blocks in file order, first match wins, so placement here isn't cosmetic) — in both the HTTP block and the commented-out HTTPS template. Also replaced docs/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 the view.discussion.show.before_content / view.discussion.badges hooks 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 through view.post.content.render (which re-checks group-restricted shortcodes per viewer, never cached) nor, for CMS pages backed by a discussion, through view.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 configured blog_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 .htaccess is now self-healing like stockage/.htaccess already wasApp::ensureStorageHtaccess() recreated stockage/.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/.md files via mod_rewrite, with the stockage/ one only a fallback for hosts where mod_rewrite is 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. New App::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 requestAuthMiddleware::isAuthenticated() and Controller::requireAuth() only re-verified that the account still exists when permissions_version was present in the session. That field is set at login by LoginController, 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 filteradmin/users.php's $sortLink closure referenced $filterGroup in its generated query string without capturing it via use(...), so it was always null inside the closure (Warning: Undefined variable $filterGroup) and the group parameter 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 itSqliteStorage::deletePost() ran a plain DELETE FROM posts with no cleanup of dependent rows. post_reactions, mentions, and edit_history all reference posts(id) with no ON DELETE CASCADE, and posts.parent_id (self-referencing, used for reply/quote chains) has no ON DELETE clause 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 uncaught PDOException and leaving the post undeleted. post_reactions cleanup used to be handled ad hoc in the controller via ReflectionClass to reach SqliteStorage's private PDO handle — moved into deletePost() itself (now wrapped in a transaction) alongside the two other tables, plus UPDATE posts SET parent_id = NULL WHERE parent_id = :id to 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.help instead of help text, in every localeadmin/bans.php's ban-reason field used Translator::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 missing bans.reason.help key to all 6 admin.json locale 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, EasyMDE 2.3.19 → 2.3.20) — the mention picker detected which markdown editor was active by duck-typing an editorInstance.codemirror property, 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 replySqliteStorage::updateDiscussion() filters incoming fields through a column whitelist (DISCUSSION_UPDATABLE_COLUMNS) before writing, and that whitelist never included best_answer_id, best_answer_set_by, or best_answer_set_at. On SQLite-backed installs (the active storage backend), the UPDATE query ran and returned successfully — it just silently dropped those three fields, so the discussion's updated_at changed but the best-answer marker never did. The controller compounded this by ignoring Discussion::setBestAnswer()'s boolean return value and always responding with success: 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.php correctly 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-color custom 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 via color-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_verification is enabled, an account whose verification email never arrived (SMTP unconfigured and the mail() fallback also failing, a common case on shared hosting) used to be stuck permanently: the account exists, email_verified stays 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-verification page lets the user request a new verification email themselves (new EmailVerificationController::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 (LoginController flashes a dedicated show_resend_verification flag for that one redirect) — not as a permanent link sitting next to "Forgot password" for every visitor regardless of context. New translation keys under email.verification.resend.* (6 locales), and register.successVerifyEmailSendFailed now 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 native mail() fallback (no SMTP) had no way to confirm email could be sent before flipping the toggle on. It now tests whichever path send() actually uses in production: SMTP if configured, mail() otherwise. A successful test (either path) records email_test_verified_at; ConfigController::update() now rejects turning email_verification on (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.php wrapped 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 the mail() 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-config block 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 global fetch interceptor auto-toasts any JSON error response unless the route is in its manualErrorRoutes exclusion list, and admin/config/update/test-email/check-dns weren't in it — even though config-management.js already 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 defaultinstall.php wrote email_verification => true unconditionally into the fresh config.json, regardless of whether the (optional, unchecked-by-default) SMTP step was filled in or ever tested. Every new install was therefore one unlucky mail() 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 to false; 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. New form.smtp.verificationNotice key (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, TUIEditor 1.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}/edit but 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, TUIEditor 1.3.14 → 1.3.15) — the same URL allow-list matched against the raw, unnormalized $_SERVER['REQUEST_URI'] instead of going through Request::getUrl(), the only place that strips a configured base_url and 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 build needsEditor()'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, FlatModerationExtend 1.0.13 → 1.0.14, ForumMonitoring 1.1.7 → 1.1.8) — auditing every $_SERVER['REQUEST_URI'] usage in plugins/ and themes/ for the same raw-matching pattern that caused the editor bug above turned up a systemic habit across ~20 files. Highlights: FlatHome's detectCurrentTemplate()/setBannerData() never recognized /page/{slug} or /blog routes 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's isDiscussionListPage()/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 from Request::getUrl(), the only place that strips a configured base_url. A dozen more non-packaged plugins had the identical bug (logged individually in their own CHANGELOG.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, premium 5.2.0 → 5.2.1, Horizon 1.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 own CHANGELOG.md). Now built from Request::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, Horizon 1.0.3 → 1.0.4, bootswatch 1.0.2 → 1.0.3, ClassicForum 1.0.2 → 1.0.3, IPB 1.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 substring contains() check and was unaffected). Now built from Request::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 across plugins/ and themes/ 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 raw error_log() call dumping the full toolbar configuration on every load of the admin settings page, regardless of any error condition. Unlike the app's own Logger::debug() (gated by the configured log level, written to its own rotated debug.log), a raw error_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 raw error_log() (FlatPolls' SQLite storage) only does so inside catch blocks for actual database errors, and everything else already goes through the gated Logger::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, since innerHTML-inserted scripts don't run on their own). Two stacked CSP nonce bugs, both needed fixing:
    1. PostApiController::editForm()/htmlError() wrote their response with a raw echo instead of going through the framework's Response pipeline, 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 shared components/attachments.php) shipped with no nonce at all. Fixed by routing both responses through Response::html().
    2. 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 .nonce IDL 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, Horizon 1.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: 1000 vs Flatbot's 1040), 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 to bottom: 6rem), and its z-index was lifted to 1041. 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 — plus ux-enhancements.js); the inline script is removed and the single remaining handler toggles the .show class the theme CSS already animates, giving a proper fade+scale entrance (with a prefers-reduced-motion fallback and a :focus-visible ring 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.removed key, 6 locales). Files changed: app/Views/components/attachments.php, languages/{fr,en,de,pt,zh,pl}/main.json.
  • Mutually-exclusive plugin groups (exclusive_group manifest key) — a plugin can declare "exclusive_group": "<name>" in plugin.json to mean "only one active plugin of this kind at a time". Activating one from /admin/plugins now automatically deactivates every other active plugin sharing the same group (writing active: "0" to their manifests, pulling them from plugins.enabled, calling their deactivate(), and clearing their permissions/asset caches), and the admin is told which plugins were switched off (new plugins.exclusive_deactivated key, 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 declare exclusive_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-logs can't be silently turned off from the plugins UI by a co-admin or a compromised account (core file logging and the core AuditLog page 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 generic form_config form and its dedicated admin page, with incompatible storage schemas (the generic one couldn't even enable webhooks). The redundant form_config is dropped and the new settings_url key 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_url manifest key) — plugins whose entire configuration lives on their own admin page rather than in a "plugin" settings section (StorageMigrator and its /admin/storage-migrator page 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/..." in plugin.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.enabled entry in config.json silently 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/plugins yet 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 list plugins.enabled first and booted anything listed there regardless of plugin.json's active field, while the admin list (and the toggle) treated plugin.json active as the display truth — so any desync (a hand-edited manifest, an interrupted toggle, a plugin reinstalled with active: "0" over a still-listed id) ran plugins the admin believed were off. plugin.json's active field is now the single source of truth wherever it exists: the loader obeys it and self-heals plugins.enabled in 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), and Plugin::activate()/deactivate() write the flag to plugin.json too so no caller can recreate the divergence. The config list keeps deciding only for legacy manifests that have no active field, 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 (new Plugin::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 (new plugins.disabled_settings_redirect key).

    📓 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 had icon/color fields (SQLite tags table, admin UI at /admin/tags) — the theme just wasn't reading them. Falls back to a neutral gray fas fa-tag icon 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 (new notification.types.system.{auth_plugins_conflict,captcha_misconfigured} keys).
  • markdown:rebuild CLI command now auto-applies --force when debug is enabled in config — found while debugging the viewer-dependent rendering fix below (Post::rendered_html): without --force, the command skips any post whose content_hash still matches, even if its cached rendered_html was generated under old hook logic (e.g. before a plugin update) — the raw content didn't change, but what a markdown.pre_process hook 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 --force themselves. Files changed: app/Cli/Commands/RebuildMarkdownCommand.php, docs/17-advanced.md (documented the --force flag, 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 for card_style/navbar_style/avatar_border_radius/animation_level/enable_gradients/enable_shadows/enable_parallax set accordingly in theme.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.php loaded theme-presets.js from a hardcoded themes/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 from themes/premium).
  • New view.profile.cover hook, 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 core users schema — a "cover photo" plugin can follow the same pattern instead of requiring a SCHEMA_VERSION bump for everyone). users/profile.php is a single core file with no per-theme copies, so the hook (and its minimal .profile-cover-wrap CSS 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. Added jpg/jpeg/png/gif/webp and mp4/webm/mov to that allowlist, added the three new video types (with icon/MIME metadata) to PermissionController::getFileTypes(), and added the corresponding checkboxes to the admin "Médias & Pièces jointes" panel. While fixing this, found AttachmentHelper's own (separate, broader) extension allowlist was missing md even though the admin panel already allowed enabling it as an attachment type — a pre-existing inconsistency between the two lists — so md was 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_html caching 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.php and its premium/Horizon theme copies echo it verbatim whenever content_hash still matches) is reused for every future viewer, but MarkdownHelper::parse()'s markdown.pre_process hook — 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 $cacheable parameter (the four post/discussion create+edit call sites, plus the RebuildMarkdownCommand CLI rebuild, pass true), 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 new view.post.content.render hook (fired from all three post-thread.php copies, cache hit or miss, with the post's author_id in the payload). Purely viewer-independent markdown.pre_process hooks (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 persist rendered_html the 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(), not RebuildMarkdownCommand), so fixing the CLI command didn't cover it. All MarkdownHelper::parse() call sites that write to rendered_html were 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 real Post/Discussion record, 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. New view.post.content.render hook documented in docs/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 (UploadControllerUploadService::upload()) correctly read uploads.attachments.max_size from config, but the second validation pass that runs when the post is saved (AttachmentHelper::validateAttachments(), called from PostController::store()) checked the attachment size against a hardcoded DEFAULT_MAX_SIZE constant (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 reads uploads.attachments.max_size from 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.org who couldn't reach /admin on v5.flatboard.org (a subdomain), but could in a private-browsing window. Session::configureSecureSession() never set session.name or session.cookie_domain explicitly, so both fell back to the hosting's PHP defaults (PHPSESSID, and potentially a shared session.cookie_domain across 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, so Controller::requireAuth() found no user_id and redirected out of /admin. Now always forces a Flatboard-specific cookie name (flatboard_session) and a host-only cookie_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 datesAvatarHelper::render()'s detailed mode concatenated $user['created_at']/last_activity straight into the tooltip text (e.g. "Dernière visite: 1785318830") instead of formatting them, unlike profile.php and components/post-thread.php's own tooltip-building code, which already used date('d/m/Y', …) and DateHelper::ago(...). Checked the two themes with their own avatar.php copies (premium, Horizon) — both already formatted these dates correctly, so only the shared core helper (used by default, 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 usernameTranslator::trans('common.label.avatar') was called without the username replacement 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 the success.securityNote warning unconditionally, outside the if (!empty($installerRemoved))/else branch that already chooses between the "removed automatically" and "removal failed, do it manually" messages. securityNote now only renders in the else branch, alongside installerManual, 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 by implode()-ing $report['errors'] directly, but each entry pushed by JsonToSqliteMigration/SqliteToJsonMigration is 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 through Translator::trans() (new storagemigrator domain keys added to the 6 core locales; the two CSRF messages reuse the existing errors.security.csrfInvalid key). 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, overriding Response::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 through Translator, so any validation error on those endpoints was French-only for every visitor. Both now go through Translator::trans(); a new validation.rules.* block (33 keys) was added to the errors domain in the 6 core locales, with each message keeping a literal {field} placeholder so Validator::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\DnsHelper lookup method (checkSPF, checkDKIM, checkDMARC, checkMX) only caught \Exception, not \Throwable; on a host where dns_get_record() is disabled (e.g. via disable_functions) or otherwise unavailable, PHP raises a fatal \Error that went uncaught, producing a raw PHP error output instead of JSON — which the frontend (config-management.js) then failed to JSON.parse(), surfacing the generic fallback message instead of anything actionable. All four methods now catch \Throwable, and ConfigController::checkDnsRecords() additionally wraps the whole DNS check in a try/catch(\Throwable) as a second line of defense, always returning a proper JSON success:false response with a clear message instead of letting any unexpected error escape as non-JSON output. Both new server-side error messages go through Translator::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 of DnsHelper'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 through Translator::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() called EmailService::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 new register.successVerifyEmailSendFailed warning 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 deletedloadNotificationsUnified() (themes/assets/js/main.js) and updateNotificationList() (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 /notifications page) — 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"#postId citation 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's replyToPost()), and is normally consumed by MarkdownHelper::parse() when the post body itself is rendered — but the hover-preview API (/api/posts/{id}?preview=true, backed by PostPreviewGenerator) builds its excerpt straight from the raw, unrendered content via strip_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() in categories-management.js looks up keys directly in window.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-existent admin.categories.title.delete (categories.title is a plain string, not an object). All call sites now use fully domain-qualified keys; four new keys were added to the admin domain (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 its admin./errors. domain prefix, and (2) a <feature>.title.<action> (or similar) lookup where <feature>.title is 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 nonexistent window.Translations.validation root instead of window.Translations.errors.validation; the save-button's "Saving…"/"Save" states were also reading a nonexistent admin.users.saving/save instead of the shared admin.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 existing clear_all/clear_themes/clear_plugins labels instead of a broken title.* lookup).
    • theme-config-management.js — the settings-saved toast was reading the wrong key path entirely (themes.settings.saved instead of the real themes.config.settings_saved); the delete-logo dialog title had no key at all.
    • reports-management.js — the delete/purge confirmation dialog titles.
    • reactions-management.js and DragDropManager.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 the admin domain (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 the errors domain (validation.username.{tooShort,tooLong}, validation.required.group, general.validationError), and to the main domain (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 existing discussion.search.*/common.label.* keys.
    • user-tooltip-manager.js — the hover-card shown over usernames (Discussions/Replies counts, Joined/Last seen, Signature, View profile) read window.Translations.main.X instead 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 nonexistent main.load_more; now uses the existing common.button.loadMore.
    • notification-manager.js — the notification bell's relative timestamps ("2 minutes ago", "3 days ago"…) looked up ago_seconds/ago_minutes/etc., which don't exist anywhere; the exact same relative-time strings already exist server-side for DateHelper::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's getTranslation() 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.js and draft-manager.js — both read window.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 call window.__() directly against the real discussion.attachment.*/discussion.draft.* keys (draft-manager.js is currently dead code — DraftManager is never instantiated — so this is a latent fix, not a live one).
    • auth-2fa-manager.js — its generic 2FA-toggle handler expected enabled/disabled translation keys, but the only place that ever populates this object (2fa-settings.php) provides enabledSuccess/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, PrivateMessaging 1.1.9, FlatHome 1.0.22 — PHP-side Translator::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. Because Translator::trans() returns the raw key string itself on a miss (not an empty value), a Translator::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:
    • LoggerLoggerAdminController used the core admin domain with a logger.-prefixed key for 6 settings/webhook-test messages, instead of the plugin's own logger domain 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 bare online/offline keys in the core main domain; neither exists there (nor with no fallback at all, so the literal word was shown verbatim). Now uses the plugin's own existing label.online/label.offline keys.
    • FlatHome — the contact-form rate-limit error looked up a rate_limit_exceeded key that doesn't exist anywhere; switched to the existing core errors.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

  • 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.modal cleanup). It's now a small popup anchored to the button: icon-only circular buttons (name shown on hover via native title, 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.discussion stacking 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 to document.body in 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 in main.js): on open, the popup is detached to document.body, positioned with position: fixed from the button's getBoundingClientRect() (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 in reaction-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-menu needs its own display: none default now that it no longer piggybacks on Bootstrap's .dropdown-menu class, so any theme missing this rule would have shown the popup permanently inline on every post. bootswatch in particular never loads a frontend.css of its own (only frontend-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/discussions and category pages stack the sidebar column (Forums / Tags / category list / Statistics, ~1500 px tall) above the actual discussion list below the lg (992 px) breakpoint, on every phone and every portrait tablet, because the two Bootstrap grid columns had no responsive order set (source order=sidebar first). Content now renders first below lg via order-1/order-2 (order-lg-* restores the desktop layout). Core view (default/NordTheme/terminal/bootswatch) and the premium theme's own copy, both discussions/index.php and categories/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). Core discussions/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-toggler in the core header (default/NordTheme/terminal) and its premium/bootswatch/ClassicForum copies (IPB has no navbar-toggler — unaffected), and the submit buttons in auth/{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 postercommon.label.avatar was 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 in post-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 to 5.1.9, bootswatch to 1.0.2, ClassicForum to 1.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

  • 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.