Release date: June 6, 2026


Extensibility

  • New upload.image.saved hook — post-process uploaded images from a plugin — Image uploads had no extension point: a plugin could not compress, strip or convert an image as it was saved. UploadService now fires upload.image.saved right after an image is written to disk, on every path that stores an image — the editor image route (UploadService::upload()), and the avatar/resize path (uploadAndResizeImage(), both the resized and copied-as-is branches). The hook is a filter: the path, filename and full_path keys are passed by reference, so a plugin that rewrites the file (e.g. converting PNG → WebP and renaming it) sees the URL returned to the caller — and therefore the one the editor inserts into the post — updated to match. The payload also carries extension, mime, context ('upload' by default, overridable via the image_context upload option), destination and a resized flag. Non-image uploads never trigger it. Files changed: app/Services/UploadService.php.
  • Plugin requirements can now declare system binariesplugin.json requires gained a binaries array alongside the existing flatboard / plugins / php / extensions keys. PluginHelper::checkDependencies() checks each listed executable against the server PATH and reports any missing one as an unmet requirement, so the existing activation guard (PluginController::toggle()) blocks the plugin from being enabled until the tool is installed. The check (PluginHelper::isBinaryAvailable()) is hardened: the binary name is strictly validated to prevent command injection, it works whether or not shell_exec is available (falling back to a manual PATH scan), and it is cross-platform (command -v / where). Files changed: app/Core/PluginHelper.php.

Fixed

  • Admin layout — view.admin.main.before overwrote the page content — the backend main.php iterated plugin-injected fragments with foreach ($pluginBeforeContent as $content), reusing the $content variable that holds the admin page body. As a result, any plugin injecting HTML through view.admin.main.before had its fragment rendered twice and the real admin page (for instance a plugin's own settings form) was replaced by that fragment. The loop variables are renamed to $beforeFragment / $afterFragment so the page content is preserved. Files changed: app/Views/layouts/backend/main.php, themes/{premium,bootswatch,ClassicForum,IPB}/views/layouts/backend/main.php.

Updates

  • Local update archives now light up the "update available" indicatorUpdateController::hasUpdateAvailable() only consulted the remote update_check_url, so a Flatboard update uploaded locally (Pro package dropped in by hand, or an offline server) was sitting ready in stockage/updates/ without the admin being told. The check now scans the local archives first and returns true as soon as one is newer than the installed version — independent of any remote check. Files changed: app/Controllers/Admin/UpdateController.php.
  • Uploading a local update jumps straight to the Updates page — when an uploaded archive is detected as a Flatboard update (not a backup), the backend already returned is_update + a redirect_url; the admin JS now follows it, sending the admin directly to Admin → Updates instead of reloading the backups page. One less needless click before hitting "Update". Files changed: themes/assets/js/admin/modules/backups-management.js.
  • Static JS/CSS are now cache-busted (?v=<mtime>) — fixes stale assets after an update — the server sends a one-year immutable cache header on JS/CSS (.htaccess/nginx, whose comment even assumes "versioned ?v= filenames"), but AssetLoader served source assets with no version query at all. Returning visitors therefore kept the old file for up to a year after a deploy, so a front-end fix — or the entire stylesheet, since CSS minification is disabled — silently never took effect until a hard refresh. AssetLoader::loadJs() / loadCss() now append ?v=<filemtime> to every asset URL (which also covers plugin assets, since PluginAssetHelper::loadCss()/loadJs() delegate to AssetLoader; PluginAssetHelper::getAssetUrl() is versioned too), so the URL changes whenever the file changes and the immutable cache behaves as intended. As secondary housekeeping, CacheInvalidator::invalidateAssets() (and the manual Clear cache) also wipe the per-file minified cache (themes/cache/) via a new AssetLoader::clearCache(). Files changed: app/Helpers/AssetLoader.php, app/Helpers/PluginAssetHelper.php, app/Core/CacheInvalidator.php, app/Controllers/Admin/MaintenanceController.php.

🚀 Changelog — Flatboard 5.7.1 — BEACON

Release date: June 5, 2026


Security (installer hardening)

  • install.php now emits security headers — before any HTML output the installer sends X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer and a restrictive Content-Security-Policy (frame-ancestors 'none', form-action 'self', base-uri 'self'). The setup pages, which carry the admin credentials form, can no longer be framed or have their form action hijacked. Files changed: install.php.
  • install.php self-deletes after a successful install — once the .install.lock is written, the installer attempts @unlink(__FILE__) (the file is already loaded in memory, so output completes normally). The .lock 403 guard remains the primary protection if the unlink fails on a read-only filesystem; the success screen now shows either a green "installer removed" confirmation or an amber "remove it manually now" warning depending on the result. New keys success.installerRemoved / success.installerManual added in all 6 locales (fr/en/de/pt/zh/pl). Files changed: install.php, languages/{fr,en,de,pt,zh,pl}/install.json.
  • Stronger admin password policy — the minimum length goes from 8 to 10 characters and a complexity rule now requires at least one lowercase letter, one uppercase letter, one digit and one special character (enforced server-side via the validator and client-side via the input pattern). security.password_min_length in the generated config is bumped to 10 accordingly. Files changed: install.php.
  • Timezone and default-language now whitelisted before use — the submitted timezone is validated against timezone_identifiers_list() and default_language against the languages actually present on disk; either falls back to a safe default (Europe/Paris / fr) instead of being written verbatim into config.json. Files changed: install.php.
  • Fixed: the installer could re-run on an already-installed site — the "already installed" guard ran the config/lock paths through Sanitizer::sanitizePath($absolutePath, BASE_PATH), which expects a path relative to the base and therefore doubled BASE_PATH (…/Flatboard/home/…/Flatboard/stockage/json/config.json). file_exists() was always false on the mangled path, so neither the .install.lock nor the config.json check ever triggered and the installer would run again over a live install. The trusted constant paths are no longer passed through the sanitizer. Detection is also broadened and unified: a valid non-empty config.json (the file App\Core\Config reads for both JSON and SQLite data backends) now counts as "installed" even when .install.lock is missing, and instead of silently redirecting to the homepage the installer shows a proper "Flatboard is already installed" page (HTTP 403), honoring the secure bypass token. New error.alreadyInstalled.messageNoDate key added and the hint updated in all 6 locales. Files changed: install.php, languages/{fr,en,de,pt,zh,pl}/install.json.

Editors

  • Emoji picker for EasyMDE and TUIEditor — both Markdown editors previously offered a single flat grid of ~100 emojis. They now share a proper categorized picker: a popup with 9 category tabs (Smileys, People, Animals, Food, Travel, Activities, Objects, Symbols, Flags), a search box that matches on keyword/name, insertion at the cursor, light/dark theming, and close on outside-click or Escape. It's a standalone, dependency-free component (themes/assets/css/shared/emoji-picker.css + themes/assets/js/shared/emoji-picker.js) reusable elsewhere, with category labels localized in all 6 languages (skin-tone variants are out of scope for now). EasyMDE 2.3.13 (Community), TUIEditor 1.3.11 (Pro). Files changed: themes/assets/css/shared/emoji-picker.css (new), themes/assets/js/shared/emoji-picker.js (new), plugins/EasyMDE/dist/easymde-init.js, plugins/EasyMDE/EasyMDEPlugin.php, plugins/TUIEditor/dist/tuieditor-init.js, plugins/TUIEditor/TUIEditorPlugin.php.

Installer (UX)

  • 4-step setup wizard — the installer is no longer a single long page. The form is now split into four guided steps (Site configuration → Admin account → SMTP → Review & install) with a numbered progress indicator at the top, Back/Next navigation, and per-step client-side validation before advancing. The final step shows a recap of the entered values before launching. It degrades gracefully without JavaScript (all sections and the install button remain visible). Files changed: install.php, languages/{fr,en,de,pt,zh,pl}/install.json (new wizard.* block).

Password strength component (reusable)

  • Shared password strength meter, now on member registration too — the live strength meter (a colored progress bar from red through to green, plus a requirements checklist that ticks off as you type) has been extracted from the installer into a standalone, dependency-free component: themes/assets/css/shared/password-strength.css and themes/assets/js/shared/password-strength.js. It auto-initializes on any <input data-password-strength> and reads its labels/options from data-* attributes (data-pw-min-length, data-pw-require-special, data-pw-labels), so it is fully translatable per context. The installer was refactored to consume the shared component instead of its previous inline copy. Files changed: themes/assets/css/shared/password-strength.css (new), themes/assets/js/shared/password-strength.js (new), install.php.
  • Member registration now enforces the same strong password policy — registration moves from a bare min 8 to min 10 plus a complexity rule requiring one lowercase letter, one uppercase letter, one digit and one special character (server-side in RegisterController via the validator, client-side via the input pattern), matching the installer's admin-account policy. The registration form shows the strength meter and an updated rules hint. New validation.password.weak key (errors domain) and register.passwordStrength.* block (auth domain) added in all 6 locales; register.form.passwordRules updated. Files changed: app/Controllers/Auth/RegisterController.php, app/Views/auth/register.php, languages/{fr,en,de,pt,zh,pl}/errors.json, languages/{fr,en,de,pt,zh,pl}/auth.json.

Admin

  • Create tags directly from the admin panel — until now Admin → Tags could only list, rename and delete tags; a tag could only be born by typing it into the Tags field of a discussion, which is non-obvious (the page is empty on a fresh install, with no way to add one). A "Create tag" button now sits in the page header and opens a modal (name + FontAwesome icon + color, with a live preview). It posts to a new admin-only, CSRF-protected route POST /admin/tags/create (TagController::create), which slugifies the name, rejects duplicates, fires the tag.created hook, and reloads the list. New panel.tags.create.* keys added in all 6 locales. Files changed: app/Controllers/Admin/TagController.php, app/Core/App.php, app/Views/admin/tags.php, languages/{fr,en,de,pt,zh,pl}/admin.json.

Fixed

  • "View all results" (and other search links) no longer 403 on multi-word queries — the search dropdown's "View all results" button, the search-page form submissions, the back-to-search button and the search "load more" all built the URL with the query in the path (/search/{query}), so a phrase like "Username with unsupported characters" became /search/Username%20with%20unsupported%20characters. Encoded spaces in a URL path trip many server WAF rules (mod_security in particular), which return a 403 before the app even runs — so the dropdown worked (it uses the API) but clicking through to the full results page failed. Every search navigation link now uses the query-string form /search?q={query}, which SearchController already accepted and which WAFs don't object to. Files changed: themes/assets/js/search.js, themes/assets/js/frontend/modules/load-more-manager.js, app/Views/discussions/search.php, themes/premium/views/discussions/search.php.
  • Updates and "Clear cache" now reset PHP OPcache — applying an update (Admin → Updates) flushed the file cache but left PHP's compiled-bytecode cache serving the old code until php-fpm was reloaded by hand — the classic "my fix isn't showing up in production" trap. The in-app updater now calls CacheInvalidator::invalidateAll(true), and the Admin → Maintenance → "Clear cache" action calls opcache_reset() too (and reports it). On a normal manual deploy, hitting "Clear cache" once is now enough to load the new code, no shell access required. New maintenance.cleanup.cache.opcache_cleared key in all 6 locales. Files changed: app/Controllers/Admin/UpdateController.php, app/Controllers/Admin/MaintenanceController.php, languages/{fr,en,de,pt,zh,pl}/admin.json.
  • Some member profiles 404'd because of case-sensitive username lookupSqliteStorage::getUserByUsername() matched the exact (case-sensitive) username first, then fell back to the indexed username_normalized column. But that column was only ever filled by the one-time migration that created it: createUser() / createUserWithId() never set it, so every account created or imported afterwards had username_normalized = NULL. When a profile URL's casing didn't match the stored name (e.g. /u/nononsense for a user stored as NoNonsense), neither lookup matched and the profile returned "not found" — which, rendered with the homepage banner, looked like being bounced to the homepage. Fix: both insert paths now populate username_normalized; the lookup fallback also catches NULL-normalized rows via LOWER(username); and a migration (SCHEMA_VERSION 19) backfills every NULL/empty value with the PHP-normalized username on next startup. JSON storage was already case-insensitive (runtime index) and is unaffected. Files changed: app/Storage/SqliteStorage.php.
  • Username cache made case-insensitive and kept in sync when a username is editedUser::findByUsername() cached records under md5($username) (case-sensitive), so /u/nononsense and /u/Nononsense used separate cache entries, and the cache also stores negative (null) results for 15 minutes. The upshot: a single failed lookup could "stick" a profile as not-found until the entry expired, and editing a username from the admin left the old casing serving a stale record (the frontend profile header showed the old name while the admin user list showed the new one). The cache key is now normalized (lowercased) so every casing shares one entry, SqliteStorage::updateUser() recomputes username_normalized whenever the username changes (admin edits previously left it stale), and renames also invalidate the old username's cache key. After deploying, clear the server cache once (stockage/cache/) to drop any already-poisoned entries. Files changed: app/Models/User.php, app/Storage/SqliteStorage.php.
  • Restricted profile links no longer silently dump you on the homepage — on a forum where profile viewing is restricted (the guest group lacks profile.view — which also covers not-yet-verified members, since registration places them in the guest group until they confirm their email), clicking a member profile sent the visitor to /login without remembering the target, and GET /login then bounced any browser that still carried a session straight to /. The destination was lost and the user landed on the homepage. The guest redirect now carries the profile as a relative ?redirect= return target (no open redirect), and GET /login honors that target for already-authenticated users instead of always going to /, so visitors come back to the profile after logging in. Who may view profiles stays entirely admin-controlled (Admin → Permissions); only the redirect UX changed. Files changed: app/Controllers/User/UserController.php, app/Controllers/Auth/LoginController.php.

🚀 Changelog — Flatboard 5.7.0 — BEACON

Release date: June 4, 2026

Codename: BEACON — bridges the watchtower lineage of BASTION/AEGIS with the visibility lineage of LIGHTHOUSE. The release bundles the closing pass of the 5.6.x security audit (5.6.8 + 5.6.9) with the real-time sitemap and RSS/Atom invalidation pipeline — a beacon that stays in sync with what's actually on the forum.


Packaged plugin versions

Bundled plugins updated in this release, by package edition:

  • Community package: EasyMDE 2.3.12
  • Pro package (includes everything in the Community package): FlatModerationExtend 1.0.7, TUIEditor 1.3.10

Security

  • Stored XSS in Markdown editors (EasyMDE, TUIEditor) — fixed — both editors render with Parsedown in non-safe mode (setSafeMode(false)) to keep rich HTML, and only stripped <script> tags. Inline event handlers (<img src=x >, <div >) and URLs (in both [text](…) Markdown links and raw <a href>) passed through and executed when a post was viewed — a stored XSS exploitable by any author. A new core helper App\Core\Sanitizer::stripXss() removes <script>, all on* handlers, dangerous URL protocols (///) and non-image data-URIs without stripping legitimate rich HTML, and both editors' cleanAndSanitize() now route their output through it. Verified against onerror/onmouseover/ payloads (neutralized) and normal content (bold, http links, images, code blocks render unchanged). Files changed: app/Core/Sanitizer.php, plugins/EasyMDE/libs/EasyMDEHelper.php, plugins/TUIEditor/libs/TUIEditorHelper.php, plugins/TUIEditor/TUIEditorPlugin.php.

SEO

  • Sitemap and RSS/Atom feeds — immediate invalidation on discussion and post mutations — Until now, deleting a discussion left it visible in /sitemap.xml indefinitely (the static public/sitemap.xml was served directly by nginx and never regenerated automatically) and for up to 15 minutes in the RSS/Atom feeds (RssService::CACHE_TTL = 900). Creating or editing a discussion suffered the same lag. A new App\Helpers\SeoCacheInvalidator purges the three layers — SitemapService application cache (sitemap:xml), the static public/sitemap.xml file (so the next request to /sitemap.xml regenerates it through PHP), and all rss:feed:* / atom:feed:* cache entries — and is now called from Discussion::{create,update,delete} and Post::{create,update,delete}. Replies are covered because the first post of each discussion is embedded as the RSS item body and any post mutation moves the discussion's updated_at (sitemap lastmod). Browser/CDN cache (Cache-Control: public, max-age=3600 on /sitemap.xml) is the only remaining staleness layer and is out of server-side control. Files changed: app/Helpers/SeoCacheInvalidator.php (new), app/Models/Discussion.php, app/Models/Post.php.

Moderation

  • Category::decrementPostsCount() — optional batch count — The method now accepts a second argument int $count = 1 and subtracts it in a single read/update/cache-invalidation cycle. Previously the only way to decrement by N was to call it N times. Fully backward-compatible (default 1). Files changed: app/Models/Category.php.
  • FlatModerationExtend (Pro) — bulk discussion deletion no longer does N counter writes — When deleting a discussion through the bulk tools (bulkDiscussions and bulkSubmit), the plugin decremented the category posts_count inside a for loop running once per post. Deleting a 300-post discussion meant 300 separate find + update + cache-clear cycles on the same category file. It now calls Category::decrementPostsCount($categoryId, $postCount) once. Files changed: plugins/FlatModerationExtend/FlatModerationExtendController.php.
  • FlatModerationExtend (Pro) — pre-moderation correctness — Approving a reply from the pre-moderation queue now sets rendered_html and content_hash like the discussion branch already did, fixing inconsistent Markdown rendering on approval. Also, the pre-moderation validation hooks now always return a non-empty error message when content is queued: if the translation key was missing the message was '', which the core (PostController/DiscussionController) treats as "no error", so the content was both queued and published. Files changed: plugins/FlatModerationExtend/FlatModerationExtendController.php, plugins/FlatModerationExtend/FlatModerationExtendPlugin.php.
  • New getUsersByGroup() storage API — ends the "all users" scan for group membership — There was no way to fetch the members of a group; callers loaded the entire user base and filtered in PHP, and GroupHelper::countUsersInGroup() additionally issued one getUserGroups() call per user (N+1). A new batch method getUsersByGroup(array $groupIds) is added to StorageInterface and both backends — SQLite resolves it in a single indexed query (idx_users_group_id + a user_groups join), JSON in a single pass over users plus one read of the membership map. It covers both primary group (users.group_id) and additional groups (user_groups) and excludes soft-deleted users. GroupHelper::countUsersInGroup() now delegates to it (its result cache is preserved), and a User::byGroup() model wrapper is exposed. Note for third-party storage backends: implementers of StorageInterface must add getUsersByGroup(). Files changed: app/Storage/StorageInterface.php, app/Storage/SqliteStorage.php, app/Storage/JsonStorage.php, app/Models/User.php, app/Helpers/GroupHelper.php.
  • FlatModerationExtend (Pro) — moderator notifications optimized + additional-group fixnotifyModeratorsPremod() loaded every user on each pre-moderated submission and only matched the primary group, silently skipping moderators whose role came from an additional group. It now calls User::byGroup([adminGroupId, modGroupId]), which is both a single indexed lookup and correct for additional-group membership. Files changed: plugins/FlatModerationExtend/FlatModerationExtendPlugin.php.

UI

  • Admin sidebar logo — alignment & centering on all themes — the Flatboard logo in the admin sidebar header sat at a different horizontal offset than the navigation icons below it (the header added its own padding on top of the inner wrapper's px-3), and when the sidebar was collapsed (.admin-sidebar.minimized, 70 px) the logo stayed left-aligned instead of centering. Each theme's backend.css now pins the logo's left edge to 1.5rem (matching .admin-nav-link icons) when expanded and centers it when collapsed. Applied to both sidebar markup families: the px-3 layout (premium, default, NordTheme, terminal, bootswatch) and the cf-admin-* layout (ClassicForum, IPB). Files changed: themes/{premium,default,NordTheme,terminal,bootswatch,ClassicForum,IPB}/assets/css/backend.css.
  • Visitor tracking — only resolved pages are counted (root-cause fix) — anonymous-visitor presence tracking ran at the very start of Router::handleRequest(), before routing, so it recorded every request including 404s and scanner probes (e.g. /.well-known/traffic-advice from Chrome's Private Prefetch Proxy, /wp-login.php, /.env…) as real guests in the admin Users → Guests list. Tracking now happens inside Router::executeRoute(), after a route has matched and its middleware has passed — so paths that resolve to a 404 (handleNotFound) or are rejected by a route middleware (CSRF/auth) are never tracked. A per-request guard prevents double-counting on NextRouteException fall-through, and the API context is carried via a new $isApiRequest flag. The existing skip-list (assets, /robots.txt, /presence/update, /.well-known/) is kept for routes that do resolve but aren't pages, plus a case-insensitive blocklist of common scanner paths (/wp-, /.git, /.env, /phpmyadmin, /autodiscover/, /actuator…) as defence-in-depth. Legitimate routes (/, /d/, /f/, /u/, /admin…, /login, /search…) verified unaffected; private-discussion 403s remain excluded. Files changed: app/Core/Router.php.

🚀 Changelog — Flatboard 5.6.9

Release date: June 3, 2026


Security audit — hardening pass

  • Logout — GET → POST-only(already shipped in 5.6.8, listed here for traceability).
  • Block .php execution in /uploads/ at nginx level — The .htaccess already denied PHP execution under /uploads/, but that file is Apache-only. The fournished nginx.conf had no equivalent rule; a future admin pasting a generic location ~ \.php$ block to PHP-FPM would have made any uploaded .php executable. Added an explicit location ~ ^/uploads/.+\.(php|phtml|phar|phps|php\d+|pht)$ { deny all; return 404; } and mirrored it in the commented-out alternate config. Files changed: nginx.conf.
  • ForumImporter — SQL injection (admin-authenticated) via db_prefixBaseImporter::connect() accepted any string as db_prefix and interpolated it directly into queries like SELECT * FROM {$p}users. Although requireAdmin() gates the route, a compromised admin (or CSRF/XSS against an admin) could execute arbitrary SQL on the imported database. The prefix is now validated against ^[A-Za-z0-9_]*$ before any query runs. Files changed: plugins/ForumImporter/Importers/BaseImporter.php.
  • Plugin path resolution — strict ID formatPlugin::getPath($pluginId) resolved any string to a filesystem path via is_dir(BASE_PATH . '/plugins/' . $pluginId). With a value like .. it returned a path outside /plugins/, and PluginViewController::index() (unlike show()) did not do the realpath() containment check — opening a narrow LFI window for any file matching the layout. Plugin::getPath() now rejects any ID not matching ^[A-Za-z0-9_-]+$, which is the convention for every shipped plugin. Files changed: app/Core/Plugin.php.
  • *`/api/POST/PUT/DELETE — CSRF middleware added** — The state-changing API routes (/api/discussions,/api/postscreate/update/delete,/api/reactions/toggle,/api/notifications/mark-read,/api/presence/update,/api/typing-indicator/typing&/stop) relied on session + SameSite=Lax only. They now require a valid CSRF token. The sharedwindow.apiRequest()helper auto-injectsX-CSRF-Token(read from) on every non-GET request, so existing frontend callers continue to work without per-call changes./api/webhooks(HMAC-signed) and/api/markdown/parse(idempotent, GET-safe) are intentionally exempt. *Files changed:*app/Core/App.php,themes/assets/js/shared/api-helper.js`.
  • Login controller — debug logs no longer leak identifiers — Two Logger::debug calls echoed the submitted login identifier (email/username) and the matched DB username on every attempt. With debug enabled in production these accumulated PII in logs. Removed. Files changed: app/Controllers/Auth/LoginController.php.
  • Password reset — full hardening sweep
    • Session invalidation after reset: User::update() now bumps permissions_version whenever password_hash changes; Controller::requireAuth() checks this version against the DB and destroys the session if it has drifted. An attacker still logged in under the old password is evicted at the next protected request. Files changed: app/Models/User.php, app/Core/Controller.php.
    • Confirmation email "your password was changed": a new EmailService::sendPasswordChanged() notifies the account owner immediately after a successful reset, with a link to /forgot-password so the legitimate user can re-secure their account if they didn't initiate the change. Translations added for passwordChanged.* in all 6 locales (fr/en/de/pt/zh/pl). Files changed: app/Controllers/Auth/PasswordResetController.php, app/Services/EmailService.php, languages/{fr,en,de,pt,zh,pl}/emails.json.
    • Per-account login throttle: a new login_account bucket (10 attempts / 15 min, keyed by lowercased identifier) sits on top of the per-IP throttle so an attacker rotating IPs can't brute-force one account. Files changed: app/Core/RateLimiter.php, app/Controllers/Auth/LoginController.php.
    • Reset tokens hashed at rest: only hash('sha256', $token) is persisted to storage now; the raw token only ever lives in the email URL. A storage compromise (DB dump, filesystem leak) no longer hands an attacker valid unused tokens within their TTL window. The controller hashes the incoming token before storage lookup; the JSON storage's per-token file path becomes the hash, so the path-traversal regex from 5.6.8 still applies cleanly. Files changed: app/Controllers/Auth/PasswordResetController.php.

🚀 Changelog — Flatboard 5.6.8

Release date: June 3, 2026


Security

  • Logout — GET route removed, only POST + CSRF accepted — Previously /logout was exposed in both GET and POST. The GET form could be triggered cross-site via <img src="https://forum/logout"> on any external page, forcing visitors to be silently logged out (CSRF on logout=a denial-of-service primitive against signed-in users). All themes already render a POST form for the logout button (<form method="POST" action="/logout"> with Csrf::field()), so removing the GET has no UI impact. Files changed: app/Core/App.php.
  • Login — username/email enumeration via timing oracle closed — When the submitted identifier matched no user, LoginController::login() returned immediately without calling password_verify(), while a matching identifier triggered the full bcrypt comparison (~50-200 ms at cost 10). The response-time delta let an attacker enumerate valid accounts despite the generic auth.invalidCredentials message. The controller now always runs password_verify() against a fixed valid-format dummy hash when the user is not found, equalizing the response time. Files changed: app/Controllers/Auth/LoginController.php.
  • Password reset — token format strictly validated before any storage lookupPasswordResetController::showReset() and reset() now reject any submitted token that does not match ^[a-f0-9]{64}$ (the exact shape produced by bin2hex(random_bytes(32))). Previously the user-supplied token was passed straight to JsonStorage::getPasswordResetToken() and deletePasswordResetToken(), which interpolate it into a filesystem path (stockage/password_reset_tokens/<token>.json); a crafted token containing path separators could escape that directory. The SQLite backend was not affected because it uses prepared statements. Files changed: app/Controllers/Auth/PasswordResetController.php.

Themes (i18n fix)

  • Discussion draft restore prompt — hardcoded French string fixed — In themes/assets/js/frontend/modules/discussion-form-manager.js, the confirm() dialog asking the user whether to restore an unsaved draft was hardcoded in French ("Un brouillon non sauvegardé a été trouvé. Voulez-vous le restaurer ?"), so non-French users saw French text. It now uses the existing translation key discussion.draft.found via window.__(), which is already translated in all 6 supported languages (fr/en/de/pt/zh/pl). Reported by @nononsense in forum thread #178. Files changed: themes/assets/js/frontend/modules/discussion-form-manager.js.

🚀 Changelog — Flatboard 5.6.7

Release date: May 31, 2026


Localization

  • 🇵🇱 Flatboard is now officially translated into Polish — Thanks to @nononsense for contributing the Polish language pack (forum thread #178). The pack covers the entire core (languages/pl/ — admin, auth, emails, errors, install, main) plus the EasyMDE, Logger and LegalNotice plugins, and the default theme. Polish (pl) becomes the 6th officially supported language, alongside French, English, German, Portuguese and Chinese.

Themes (i18n)

  • Premium theme — Polish translation addedthemes/premium/langs/pl.json (119 keys) created so the default Flatboard theme is fully localized for Polish users. The community pack from @nononsense only included themes/default/langs/pl.json, leaving the production theme untranslated. Files changed: themes/premium/langs/pl.json (new), themes/premium/theme.json (5.1.2).

Plugins (i18n)

  • EasyMDE — Portuguese and Chinese translations addedpt.json (203 keys) and zh.json (203 keys) created to bring the editor in line with the now-6-language Flatboard standard (fr/en/de/pt/zh/pl). The Polish (pl.json) file was contributed by @nononsense via forum thread #178. Files changed: plugins/EasyMDE/langs/pt.json (new), plugins/EasyMDE/langs/zh.json (new), plugins/EasyMDE/plugin.json (2.3.11).

🚀 Changelog — Flatboard 5.6.6

Release date: May 27, 2026


Performance

  • Long-term cache headers on static assets (Apache).htaccess now sets Cache-Control: public, max-age=31536000, immutable (with mod_expires fallback) on CSS, JS, fonts, icons and images. Repeat visits no longer revalidate every asset against the server; the nginx config already had the equivalent rule. Files changed: .htaccess.

  • Session cache-limiter switched from nocache to private — PHP's default session_cache_limiter('nocache') emitted Cache-Control: no-store on every HTML response, which disables the browser back/forward cache (bfcache). Switched to private, which still prevents shared/CDN caching of authenticated content but allows instant back-navigation. Files changed: app/Core/Session.php.

  • Bootstrap JS now loaded with deferAssetHelper::bootstrapJs() now outputs <script ... defer>. All inline bootstrap.Modal/bootstrap.Tooltip calls in views are inside event handlers or post-DOMContentLoaded callbacks, so deferring is safe. Files changed: app/Helpers/AssetHelper.php.

  • Preload hints + CDN preconnect added to the frontend layout — Adds <link rel="preload" as="image" href="logo">, <link rel="preload" as="style" href="frontend.css">, <link rel="preconnect" href="https://cdn.jsdelivr.net"> and dns-prefetch for cdnjs in the head. The logo carries fetchpriority="high" since it is typically the LCP candidate in the navbar. Files changed: themes/premium/views/layouts/frontend/header.php.

  • Theme UI CSS bundle — Concatenated five small standalone stylesheets (theme-dark-inline.css, navbar-logo.css, search-dropdown.css, notifications-dropdown.css, toast-container.css) into a single theme-ui-bundle.css loaded from the frontend head. Reduces 5 HTTP round-trips to 1 on every page. Applied to all four bundled themes plus the core fallback layout (app/Views/layouts/frontend/header.php). The four redundant source files are then removed; notifications-dropdown.css is kept as standalone because the three backend layouts (premium, ClassicForum, IPB) load it without the rest of the bundle. Files changed: themes/assets/css/theme-ui-bundle.css (new), themes/assets/css/{theme-dark-inline,navbar-logo,search-dropdown,toast-container}.css (deleted), themes/{premium,ClassicForum,IPB,bootswatch}/views/layouts/frontend/header.php, app/Views/layouts/frontend/header.php.

  • Preload + preconnect hints on every bundled theme — The <link rel="preload"> for the logo (with fetchpriority="high") and frontend.css, plus <link rel="preconnect" href="cdn.jsdelivr.net" crossorigin> and dns-prefetch for cdnjs, are now emitted by every layout that has its own frontend/header.php (premium, ClassicForum, IPB, bootswatch). The child themes (default, NordTheme, terminal) inherit the premium layout and are covered automatically. Files changed: themes/{ClassicForum,IPB,bootswatch}/views/layouts/frontend/header.php.

Plugins (performance)

  • TUIEditor — conditional asset loading — Toast UI Editor (~250 KB of JS+CSS, brotli) is now only loaded on routes that actually render a Markdown editor (/d/*, /discussions/create, /posts/*/edit, /profile/edit, /admin/*, /private-messages*). A tuieditor.force_load hook lets a plugin/theme override the detection. Removes the editor cost from the homepage, forum listings, profile views and search pages. Files changed: plugins/TUIEditor/TUIEditorPlugin.php, plugins/TUIEditor/plugin.json (1.3.9).

  • PrivateMessaging — assets scoped to /messages* and /admin* — The 22 KB private-messaging.css (all selectors prefixed .pm-) and the user-search.js injection are no longer loaded on every page; both gates check the request URI. The user-search.js injection now also carries defer. Files changed: plugins/PrivateMessaging/PrivateMessagingPlugin.php, plugins/PrivateMessaging/plugin.json (1.1.5).

  • ForumMonitoring — charts.css gated by /admin/ URL — The stylesheet is used exclusively by the admin monitoring views; loadStyles() now early-returns unless REQUEST_URI starts with /admin. Files changed: plugins/ForumMonitoring/ForumMonitoringPlugin.php, plugins/ForumMonitoring/plugin.json (1.1.5).

  • FlatModerationExtend — flat-moderation-extend.css gated by /admin/ URL — Same admin-only gating; no impact on frontend pages. Files changed: plugins/FlatModerationExtend/FlatModerationExtendPlugin.php, plugins/FlatModerationExtend/plugin.json (1.0.5).

  • EasyMDE — conditional asset loading — Same gating logic as TUIEditor: the EasyMDE bundle and its custom stylesheets only load on routes where a Markdown editor is rendered (/d/*, /discussions/create, /posts/*/edit, /profile/edit, /admin/*, /messages*). A easymde.force_load hook is exposed for plugins/themes that need to render the editor elsewhere. Files changed: plugins/EasyMDE/EasyMDEPlugin.php, plugins/EasyMDE/plugin.json (2.3.10).

Added

  • Core IP tracking per userregistration_ip and last_ip are now stored directly in the user record (both JSON and SQLite backends). registration_ip is set once at registration; last_ip is updated on each login when the IP has changed. Only valid, non-fallback IPs are stored (0.0.0.0 is ignored). Both fields are available without any plugin dependency. Existing SQLite databases receive the two columns automatically on next startup via the PRAGMA-based migration system (SCHEMA_VERSION bumped to 18).
    Files changed: app/Storage/SqliteStorage.php, app/Controllers/Auth/RegisterController.php, app/Controllers/Auth/LoginController.php.

  • Ban form — auto-fill user IPs on member selection — When an admin selects a member in the "Add ban" form (admin/ban), the member's last_ip (red badge, login icon) and registration_ip (grey badge) are fetched from the core user record and displayed as clickable badges below the IP field; the last IP is pre-filled automatically. A dedicated GET endpoint /admin/users/{id}/ips (requires user.ban permission) exposes this data. Works without any plugin.
    Files changed: app/Views/admin/bans.php, themes/assets/js/admin/modules/bans-management.js, app/Controllers/Admin/UserManagementController.php, app/Core/App.php.

  • Ban by user ID — automatic IP ban with admin notification — When an admin bans a member by user ID without specifying an IP, the system automatically creates a separate IP ban for the member's last_ip (falling back to registration_ip). 0.0.0.0 is never auto-banned. The IP ban is isolated in its own try/catch so a storage failure cannot mask the already-committed user ban. The JSON response now includes auto_ip_banned and auto_ip fields; the JS displays a secondary info toast ("Ban IP automatique créé pour x.x.x.x") 600 ms after the success toast so the admin is aware of the implicit IP ban and can revoke it independently if needed.
    Files changed: app/Controllers/Moderation/BanController.php, themes/assets/js/admin/modules/bans-management.js.

  • IP ban list — show members sharing the banned IP — Each IP ban row in admin/ban now displays small clickable badges for every member whose last_ip or registration_ip matches the banned IP. Banned members appear with a red badge, active members with a grey badge. Each badge links to the member's admin edit page. The IP-to-user lookup is a single batched query (one WHERE last_ip IN (…) OR registration_ip IN (…)) so the page does not incur an N+1 cost regardless of how many IP bans are listed. Duplicate users within the same IP are deduplicated server-side.
    Files changed: app/Storage/StorageInterface.php, app/Storage/SqliteStorage.php, app/Storage/JsonStorage.php, app/Models/User.php, app/Controllers/Moderation/BanController.php, app/Views/admin/bans.php.

Fixed

  • Profile — "avecLike" merged text and untranslated reaction label in Reactions tab — The phrase "a réagi avec" was hardcoded in French and rendered inside a <span> with no surrounding spaces, causing the reaction type name to be visually concatenated ("avecLike"). Fixed by replacing the hardcoded string with Translator::trans('profile.view.reactedWith') and adding surrounding spaces. The new key profile.view.reactedWith has been added to all five language files (fr, en, de, pt, zh).
    Files changed: app/Views/users/profile.php, languages/fr/main.json, languages/en/main.json, languages/de/main.json, languages/pt/main.json, languages/zh/main.json.

  • Profile — Subscriptions tab: unsubscribe button leaves row visible — The click handler used btn.closest('[data-number-slug]') to find the row to remove after a successful unsubscribe, but the button itself also carries data-number-slug, so closest() returned the button instead of the parent div.list-group-item. Only the button was removed; the discussion row remained. Fixed by targeting btn.closest('.list-group-item') instead.
    Files changed: app/Views/users/profile.php.

  • Anonymous visitor tracking — private discussion URL leaked in presence paneltrackVisitor() runs in Router::handleRequest() before route dispatch, so an anonymous visitor hitting /d/{number}-slug of a restricted-group discussion was recorded with the full URL in the visitors table, making it visible in the admin "Anonymous visitors" tab even though the controller would subsequently redirect them to login (401). Added a pre-flight check: if the URL matches /d/{number}*, the discussion's category is resolved (via cached Discussion::findByGlobalNumber() + Category::find()) and, if allowed_groups is non-empty, tracking is skipped entirely. RSS/Atom feeds and FlatSEO sitemap were already correctly filtering private categories and are not affected.
    Files changed: app/Core/Router.php.

  • Ban system — updated_at column missing from bans tablecreateGeneric() and updateGeneric() automatically inject an updated_at timestamp into every write, but the bans table schema did not declare this column, causing a SQLSTATE[HY000]: General error: 1 table bans has no column named updated_at on every ban creation attempt. Added updated_at INTEGER NOT NULL DEFAULT 0 to the CREATE TABLE bans definition, an inline migration so existing databases receive the column automatically on next startup, and incremented SCHEMA_VERSION to 17 to ensure the migration runs on already-initialized databases.
    Files changed: app/Storage/SqliteStorage.php.

  • Ban icon missing on posts and discussion lists — The banned-user SVG icon was only shown on the profile page. Post threads and discussion list items built their avatar URL manually, bypassing AvatarHelper::getUrl() and AvatarHelper::render() which are the only code paths that check Ban::isBanned(). Fixed by adding a ban check in every inline avatar-building block across all themes: post threads now override $avatarUrl when the author is banned, and the getAvatarUrlWithGravatar() / cf_getAvatarUrl() helpers in discussion-list items return the ban icon early when the user is banned. Covers all views: thread, index, tags, categories, search results.
    Files changed: themes/premium/views/components/post-thread.php, app/Views/components/post-thread.php, themes/premium/views/discussions/_discussion_item.php, app/Views/discussions/_discussion_item.php, themes/ClassicForum/views/discussions/_discussion_item.php, themes/IPB/views/discussions/_discussion_item.php.

  • Admin JS translations always falling back to hardcoded French stringswindow.Translations was defined by an inline <script> block inside footer.php (via translations.php). Page-specific JS modules like reports-management.js are loaded via <script> tags (no defer) at the end of the view file, which the browser executes immediately — before the footer is rendered. As a result, window.Translations was undefined when those modules initialized their TRANSLATIONS constants, causing UIHelpers.trans() to always return the hardcoded French fallback values. Fixed by moving translations.php from footer.php to header.php (inside <head>) so window.Translations, window.__ and window.url are guaranteed to be defined before any script in the page body runs. Same fix applied to the frontend layout.
    Files changed: app/Views/layouts/backend/header.php, app/Views/layouts/backend/footer.php, app/Views/layouts/frontend/header.php, app/Views/layouts/frontend/footer.php.

  • Backup list not refreshed after create / upload / full-archive actions — After any backup operation the UI called reloadAdminAfterAction()reloadAdminPage(), which replaces the DOM content via AJAX. Two problems prevented the list from refreshing correctly: (1) modal-fix.js moves every .modal element to <body> via a MutationObserver — when the partial AJAX reload injected fresh modal HTML into the replaced container, the observer moved it to <body> as well, creating duplicate id="createBackupModal" elements that broke Bootstrap's modal instance lookup; (2) BackupsManager is instantiated once at DOMContentLoaded and is never re-initialised after a DOM swap, leaving the refreshed create/upload buttons without event listeners. Fixed by replacing the partial-AJAX reload with a plain window.location.reload() in BackupUI.reloadPage(), which fully reloads the page, re-executes all scripts and avoids the modal duplication. Covers all three triggers: create, upload and create-full-archive.
    Files changed: themes/assets/js/admin/modules/backups-management.js.

  • Banned user badge missing on posts, profile and member list — Replacing the avatar with a ban icon was the only visual indicator for banned users; no text label appeared next to the username. Added a red <span class="badge bg-danger"> badge with a ban icon and the translated label (common.status.banned) directly after the username in all places where a user is displayed: post threads (premium theme and default fallback), the profile page, and both user-card components (_user_card.php, _user_card_modern.php).
    Files changed: themes/premium/views/components/post-thread.php, app/Views/components/post-thread.php, app/Views/users/profile.php, app/Views/users/_user_card.php, app/Views/users/_user_card_modern.php.

  • AvatarHelper::render() — undefined $username when user is banned — When Ban::isBanned() returned true, the render() method skipped the else branch where $username was assigned and proceeded to use it uninitialized in the dot, detailed, and simple switch cases (lines 300, 311, 336, 350, 355), generating Undefined variable $username warnings on the user list (/users) and profile pages. Moved $username and $sanitizedUsername assignments before the ban check so they are always defined.
    Files changed: app/Helpers/AvatarHelper.php.

  • Router — HEAD requests not matched against GET routes (RFC 7231) — The router only looked up routes by exact HTTP method, so HEAD /feed/rss (issued by RSS bots after following a FlatSEO 301 redirect) never matched the GET /feed/rss route and logged a "Route not found" warning. Per RFC 7231, a server that supports GET for a resource MUST also support HEAD. The dispatch logic now merges GET routes into the candidate set for HEAD requests, so any GET route automatically handles HEAD.
    Files changed: app/Core/Router.php.

  • Terminal theme — missing favicon.svg and logo.png — The terminal theme's assets/img/ directory only contained an index.html placeholder, causing 404 log entries on every page load when the terminal theme was active. Copied the default theme's favicon.svg and logo.png as defaults.
    Files changed: themes/terminal/assets/img/favicon.svg, themes/terminal/assets/img/logo.png.

  • Missing translation key errors.validation.invalid.url on Reports admin page — The Reports administration page displayed the raw key errors.validation.invalid.url instead of a human-readable string. The key existed in errors.json under the errors domain but was referenced via the admin domain translator, where it was absent. Added invalid.url under errors.validation in all five admin.json language files (fr, en, de, pt, zh).
    Files changed: languages/fr/admin.json, languages/en/admin.json, languages/de/admin.json, languages/pt/admin.json, languages/zh/admin.json.

  • Report notifications persisting in dropdown after "Mark all read" — After clicking "Mark all read", report-type (and any other) notifications remained visible in the notification dropdown because updateNotificationList() displayed the 5 most recent notifications regardless of their read status. The dropdown now filters to unread notifications only before slicing to 5, so marking all as read immediately clears the dropdown and shows "Aucune notification". The bell icon now also hides itself when there are no unread notifications, aligning it with the badge behaviour.
    Files changed: themes/assets/js/frontend/modules/notification-manager.js, themes/assets/js/main.js.

  • Stale user post count in admin user list after bulk discussion deletion — When discussions were deleted via FlatModerationExtend's bulk action (or any path calling Discussion::delete() directly) using SQLite storage, SqliteStorage::deleteDiscussion() only deleted the discussions row and expected the caller to pre-delete the posts. Paths that skipped this step left orphaned rows in the posts table. On the next admin user-list page load, countUserPosts() (SELECT COUNT(*) FROM posts WHERE user_id = ?) counted those orphaned rows, showing a stale non-zero count even though no discussions were visible. Fixed by adding a DELETE FROM posts WHERE discussion_id inside SqliteStorage::deleteDiscussion() before removing the discussion row, making the cleanup unconditional. Also fixed FlatModerationExtend's two bulk-delete paths to correctly decrement the category discussions_count and posts_count counters after each deletion. Added a "Delete orphan posts" maintenance action in the admin dashboard to let administrators clean up any previously orphaned rows from before this fix.
    Files changed: app/Storage/SqliteStorage.php, plugins/FlatModerationExtend/FlatModerationExtendController.php, app/Controllers/Admin/MaintenanceController.php, app/Core/App.php, app/Views/admin/dashboard.php, themes/assets/js/admin/modules/dashboard-management.js, languages/fr/admin.json, languages/en/admin.json, languages/de/admin.json, languages/pt/admin.json, languages/zh/admin.json.

Improved

  • ThemeHelper — eliminate redundant theme.json reads per requestgetThemeSetting() is called multiple times per page render (avatar border radius, show avatars, hero content, etc.), each call previously reading theme.json from disk via AtomicFileHelper. Added a static in-memory cache ($dataCache) to loadThemeData() so the file is read once per request and subsequent calls are served from memory. Cache is invalidated automatically in saveThemeData() after a successful write. This eliminates the repeated I/O that caused the theme.setting.value hook to exceed the 100 ms slow-hook threshold.
    Files changed: app/Helpers/ThemeHelper.php.

🚀 Changelog — Flatboard 5.6.5

Release date: May 24, 2026


Fixed

  • RSS/Atom feed — old discussions still resurfacing after replies (definitive fix) — The v5.6.3 fix re-sorted discussions by created_at inside the service, but the underlying storage methods (getAllDiscussions, getDiscussionsByCategory) first sort by updated_at and then apply the item limit. This meant the limit was applied to the wrong sort order: old-but-recently-replied discussions entered the top-N window before the re-sort, so they remained in the feed. The fix changes the fetch strategy: storage methods are now called without a limit so that access-control filtering and created_at sorting happen before pagination. For the global feed (/rss, /atom), getAllDiscussionsSorted('newest') is used, which sorts by created_at DESC at the SQL level (SQLite) or in the PHP sort (JSON storage) before any slicing. Pagination is applied in PHP after filtering and sorting.
    Files changed: app/Services/RssService.php.

🚀 Changelog — Flatboard 5.6.4

Release date: May 23, 2026


Fixed

  • Apache ErrorDocument fallback routes — Added routes for /403.shtml, /404.shtml, and /500.shtml in the core router. When a server is configured with ErrorDocument 403 /403.shtml (a common Apache default), Flatboard previously had no handler for that path and logged a "Route not found" warning while showing the user a confusing 404. The new routes render the appropriate Flatboard error page with the correct HTTP status code.
    Files changed: app/Core/App.php.

  • SocialLogin — Google OAuth callback blocked by server WAF — Google's callback URL includes iss=https://accounts.google.com and scope=...https://www.googleapis.com... in the query string (standard Google OIDC parameters). Servers running mod_security or similar WAF rules that block https:// patterns in the query string would return 403 before PHP ran. The Google provider now uses response_mode=form_post, causing Google to POST the auth code to the callback instead of redirecting with query parameters, so no query string reaches the WAF.
    Files changed: plugins/SocialLogin/Providers/GoogleProvider.php, plugins/SocialLogin/SocialLoginPlugin.php.

Updated

  • PHPMailer updated to 7.1.1 — Includes security improvements from 7.1.0: line-break stripping on XMailer, ContentType, CharSet, username, and password properties to prevent header injection if host applications pass untrusted data; MessageDate is now validated and auto-converted from non-RFC formats; crash fix when Mailer property is empty; Encoding validation is now case-insensitive (7.1.1). Norwegian and Turkish language files refreshed.
    Files changed: vendor/PHPMailer/src/PHPMailer.php, vendor/PHPMailer/src/SMTP.php, vendor/PHPMailer/src/POP3.php, vendor/PHPMailer/language/phpmailer.lang-nb.php, vendor/PHPMailer/language/phpmailer.lang-tr.php, vendor/PHPMailer/VERSION.

🚀 Changelog — Flatboard 5.6.3

Release date: May 22, 2026


Fixed

  • RSS/Atom feed resurfaces old discussions when they receive new replies — The feed was sorted by updated_at, so any reply bumped old discussions to the top of the feed. Atom's <updated> field also used updated_at, causing Thunderbird and other Atom readers to treat the entry as freshly updated and mark it unread again. The feed is now sorted by created_at so old discussions never bubble up, and Atom <updated> uses created_at so entry timestamps are immutable.
    Files changed: app/Services/RssService.php.

  • Terminal theme — group badge and presence dot missing in post view — The post component (post-thread.php) uses group-icon-badge size-lg for the author avatar overlay, but the terminal theme CSS only defined size-xs, size-sm, and size-md. The badge rendered with no dimensions and the top/left offset was absent, making it invisible. Added size-lg rules for both group-icon-badge (20×20 px, offset −4 px) and avatar-status to match the rest of the size scale.
    Files changed: themes/terminal/assets/css/frontend.css, themes/terminal/assets/css/frontend.dev.css.

  • Admin panel — plugin page dropdowns clipped by card container — Bootstrap dropdown menus inside plugin admin views (e.g. ResourceManager) were cut off after the first two items because the .card wrapper in backend.css had overflow: hidden, which clips absolutely-positioned descendants. The approve/reject/delete options were in the DOM but visually hidden. Fixed by changing .card to overflow: visible. Also removed the transform: translateY(-2px) lift effect from .card:hover and .admin-card:hover (retained on .btn:hover and .plugin-card:hover), which was interfering with dropdown interaction by moving the card under the cursor.
    Files changed: themes/premium/assets/css/backend.css.


🚀 Changelog — Flatboard 5.6.2

Release date: May 20, 2026


Added

  • Terminal theme — New built-in theme with a monospace, terminal-inspired aesthetic. Features a configurable accent color and full dark/light palette via theme.json variables (primary_color, bg_primary, bg_secondary, bg_tertiary, text_primary, text_secondary, border_color and their dark_ counterparts). Zero border-radius, no shadows, always-black navbar. Includes complete, self-sufficient backend.css for the admin panel. Available in all 5 languages.
    Files changed: themes/terminal/.

Fixed

  • /users page crashes with fatal error for guests without permissionUserController::index() called $this->abort(403), a method that does not exist on the base Controller class, causing an uncaught Error on every visit when the profile.view and presence.view permissions are restricted. Guests are now redirected to the login page (401); authenticated users without permission receive a proper 403 Forbidden response via $this->response->forbidden(), consistent with the rest of the controller.
    Files changed: app/Controllers/User/UserController.php.

🚀 Changelog — Flatboard 5.6.1

Release date: May 18, 2026


Fixed

  • RSS/Atom feed: old items reappear as unread in feed readers (e.g. Thunderbird) — Two bugs caused feed readers to treat already-read items as new on every fetch. (1) The RSS <guid> and Atom <id> were set to the full discussion URL including the slug (/d/123-title). When a discussion title is edited the slug changes, the identifier changes, and the feed reader treats the item as a brand-new entry — showing it again as unread. Fixed by using the slug-free stable URL (/d/123) for both <guid> and <id>. (2) The RSS <lastBuildDate> was set to date('r') (current wall-clock time), so the feed always appeared modified on every fetch regardless of content changes. Fixed by using max(created_at) of the fetched discussions, matching the existing Atom <updated> behaviour. As a bonus, the Atom <updated> per-entry now uses updated_at when available (falling back to created_at), so edited discussions report the correct modification time. Reported by arpinux.
    Files changed: app/Services/RssService.php.

Edited on  Jun 06, 2026  By  Fred .