Release date: June 6, 2026
Extensibility
- New
upload.image.savedhook — 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.UploadServicenow firesupload.image.savedright 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: thepath,filenameandfull_pathkeys 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 carriesextension,mime,context('upload'by default, overridable via theimage_contextupload option),destinationand aresizedflag. Non-image uploads never trigger it. Files changed:app/Services/UploadService.php. - Plugin requirements can now declare system binaries —
plugin.jsonrequiresgained abinariesarray alongside the existingflatboard/plugins/php/extensionskeys.PluginHelper::checkDependencies()checks each listed executable against the serverPATHand 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 notshell_execis available (falling back to a manualPATHscan), and it is cross-platform (command -v/where). Files changed:app/Core/PluginHelper.php.
Fixed
- Admin layout —
view.admin.main.beforeoverwrote the page content — the backendmain.phpiterated plugin-injected fragments withforeach ($pluginBeforeContent as $content), reusing the$contentvariable that holds the admin page body. As a result, any plugin injecting HTML throughview.admin.main.beforehad 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/$afterFragmentso 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" indicator —
UpdateController::hasUpdateAvailable()only consulted the remoteupdate_check_url, so a Flatboard update uploaded locally (Pro package dropped in by hand, or an offline server) was sitting ready instockage/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+ aredirect_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-yearimmutablecache header on JS/CSS (.htaccess/nginx, whose comment even assumes "versioned?v=filenames"), butAssetLoaderserved 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, sincePluginAssetHelper::loadCss()/loadJs()delegate toAssetLoader;PluginAssetHelper::getAssetUrl()is versioned too), so the URL changes whenever the file changes and theimmutablecache behaves as intended. As secondary housekeeping,CacheInvalidator::invalidateAssets()(and the manual Clear cache) also wipe the per-file minified cache (themes/cache/) via a newAssetLoader::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.phpnow emits security headers — before any HTML output the installer sendsX-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy: no-referrerand a restrictiveContent-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.phpself-deletes after a successful install — once the.install.lockis written, the installer attempts@unlink(__FILE__)(the file is already loaded in memory, so output completes normally). The.lock403 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 keyssuccess.installerRemoved/success.installerManualadded 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_lengthin the generated config is bumped to 10 accordingly. Files changed:install.php. - Timezone and default-language now whitelisted before use — the submitted
timezoneis validated againsttimezone_identifiers_list()anddefault_languageagainst the languages actually present on disk; either falls back to a safe default (Europe/Paris/fr) instead of being written verbatim intoconfig.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 doubledBASE_PATH(…/Flatboard/home/…/Flatboard/stockage/json/config.json).file_exists()was always false on the mangled path, so neither the.install.locknor theconfig.jsoncheck 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-emptyconfig.json(the fileApp\Core\Configreads for both JSON and SQLite data backends) now counts as "installed" even when.install.lockis 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. Newerror.alreadyInstalled.messageNoDatekey added and thehintupdated 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). EasyMDE2.3.13(Community), TUIEditor1.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(newwizard.*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.cssandthemes/assets/js/shared/password-strength.js. It auto-initializes on any<input data-password-strength>and reads its labels/options fromdata-*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 8tomin 10plus a complexity rule requiring one lowercase letter, one uppercase letter, one digit and one special character (server-side inRegisterControllervia the validator, client-side via the inputpattern), matching the installer's admin-account policy. The registration form shows the strength meter and an updated rules hint. Newvalidation.password.weakkey (errors domain) andregister.passwordStrength.*block (auth domain) added in all 6 locales;register.form.passwordRulesupdated. 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 thetag.createdhook, and reloads the list. Newpanel.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}, whichSearchControlleralready 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 callsopcache_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. Newmaintenance.cleanup.cache.opcache_clearedkey 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 lookup —
SqliteStorage::getUserByUsername()matched the exact (case-sensitive)usernamefirst, then fell back to the indexedusername_normalizedcolumn. 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 hadusername_normalized = NULL. When a profile URL's casing didn't match the stored name (e.g./u/nononsensefor a user stored asNoNonsense), 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 populateusername_normalized; the lookup fallback also catchesNULL-normalized rows viaLOWER(username); and a migration (SCHEMA_VERSION19) backfills everyNULL/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 edited —
User::findByUsername()cached records undermd5($username)(case-sensitive), so/u/nononsenseand/u/Nononsenseused 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()recomputesusername_normalizedwhenever 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/loginwithout remembering the target, andGET /loginthen 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), andGET /loginhonors 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, TUIEditor1.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 >) andURLs (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 helperApp\Core\Sanitizer::stripXss()removes<script>, allon*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 againstonerror/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.xmlindefinitely (the staticpublic/sitemap.xmlwas 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 newApp\Helpers\SeoCacheInvalidatorpurges the three layers —SitemapServiceapplication cache (sitemap:xml), the staticpublic/sitemap.xmlfile (so the next request to/sitemap.xmlregenerates it through PHP), and allrss:feed:*/atom:feed:*cache entries — and is now called fromDiscussion::{create,update,delete}andPost::{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'supdated_at(sitemaplastmod). Browser/CDN cache (Cache-Control: public, max-age=3600on/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 argumentint $count = 1and 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 (default1). 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 (
bulkDiscussionsandbulkSubmit), the plugin decremented the categoryposts_countinside aforloop running once per post. Deleting a 300-post discussion meant 300 separatefind+update+ cache-clear cycles on the same category file. It now callsCategory::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_htmlandcontent_hashlike 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, andGroupHelper::countUsersInGroup()additionally issued onegetUserGroups()call per user (N+1). A new batch methodgetUsersByGroup(array $groupIds)is added toStorageInterfaceand both backends — SQLite resolves it in a single indexed query (idx_users_group_id+ auser_groupsjoin), 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 aUser::byGroup()model wrapper is exposed. Note for third-party storage backends: implementers ofStorageInterfacemust addgetUsersByGroup(). 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 fix —
notifyModeratorsPremod()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 callsUser::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'sbackend.cssnow pins the logo's left edge to1.5rem(matching.admin-nav-linkicons) when expanded and centers it when collapsed. Applied to both sidebar markup families: thepx-3layout (premium, default, NordTheme, terminal, bootswatch) and thecf-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-advicefrom Chrome's Private Prefetch Proxy,/wp-login.php,/.env…) as real guests in the admin Users → Guests list. Tracking now happens insideRouter::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 onNextRouteExceptionfall-through, and the API context is carried via a new$isApiRequestflag. 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
.phpexecution in/uploads/at nginx level — The.htaccessalready denied PHP execution under/uploads/, but that file is Apache-only. The fournishednginx.confhad no equivalent rule; a future admin pasting a genericlocation ~ \.php$block to PHP-FPM would have made any uploaded.phpexecutable. Added an explicitlocation ~ ^/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_prefix—BaseImporter::connect()accepted any string asdb_prefixand interpolated it directly into queries likeSELECT * FROM {$p}users. AlthoughrequireAdmin()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 format —
Plugin::getPath($pluginId)resolved any string to a filesystem path viais_dir(BASE_PATH . '/plugins/' . $pluginId). With a value like..it returned a path outside/plugins/, andPluginViewController::index()(unlikeshow()) did not do therealpath()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::debugcalls echoed the submitted login identifier (email/username) and the matched DB username on every attempt. Withdebugenabled 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 bumpspermissions_versionwheneverpassword_hashchanges;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-passwordso the legitimate user can re-secure their account if they didn't initiate the change. Translations added forpasswordChanged.*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_accountbucket (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.
- Session invalidation after reset:
🚀 Changelog — Flatboard 5.6.8
Release date: June 3, 2026
Security
- Logout — GET route removed, only POST + CSRF accepted — Previously
/logoutwas 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">withCsrf::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 callingpassword_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 genericauth.invalidCredentialsmessage. The controller now always runspassword_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 lookup —
PasswordResetController::showReset()andreset()now reject any submitted token that does not match^[a-f0-9]{64}$(the exact shape produced bybin2hex(random_bytes(32))). Previously the user-supplied token was passed straight toJsonStorage::getPasswordResetToken()anddeletePasswordResetToken(), 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, theconfirm()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 keydiscussion.draft.foundviawindow.__(), 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 thedefaulttheme. Polish (pl) becomes the 6th officially supported language, alongside French, English, German, Portuguese and Chinese.
Themes (i18n)
- Premium theme — Polish translation added —
themes/premium/langs/pl.json(119 keys) created so the default Flatboard theme is fully localized for Polish users. The community pack from @nononsense only includedthemes/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 added —
pt.json(203 keys) andzh.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) —
.htaccessnow setsCache-Control: public, max-age=31536000, immutable(withmod_expiresfallback) 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
nocachetoprivate— PHP's defaultsession_cache_limiter('nocache')emittedCache-Control: no-storeon every HTML response, which disables the browser back/forward cache (bfcache). Switched toprivate, 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
defer—AssetHelper::bootstrapJs()now outputs<script ... defer>. All inlinebootstrap.Modal/bootstrap.Tooltipcalls 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">anddns-prefetchfor cdnjs in the head. The logo carriesfetchpriority="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 singletheme-ui-bundle.cssloaded 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.cssis 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 (withfetchpriority="high") andfrontend.css, plus<link rel="preconnect" href="cdn.jsdelivr.net" crossorigin>anddns-prefetchfor cdnjs, are now emitted by every layout that has its ownfrontend/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*). Atuieditor.force_loadhook 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 KBprivate-messaging.css(all selectors prefixed.pm-) and theuser-search.jsinjection are no longer loaded on every page; both gates check the request URI. Theuser-search.jsinjection now also carriesdefer. Files changed:plugins/PrivateMessaging/PrivateMessagingPlugin.php,plugins/PrivateMessaging/plugin.json(1.1.5).ForumMonitoring —
charts.cssgated by/admin/URL — The stylesheet is used exclusively by the admin monitoring views;loadStyles()now early-returns unlessREQUEST_URIstarts with/admin. Files changed:plugins/ForumMonitoring/ForumMonitoringPlugin.php,plugins/ForumMonitoring/plugin.json(1.1.5).FlatModerationExtend —
flat-moderation-extend.cssgated 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*). Aeasymde.force_loadhook 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 user —
registration_ipandlast_ipare now stored directly in the user record (both JSON and SQLite backends).registration_ipis set once at registration;last_ipis updated on each login when the IP has changed. Only valid, non-fallback IPs are stored (0.0.0.0is 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_VERSIONbumped 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'slast_ip(red badge, login icon) andregistration_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(requiresuser.banpermission) 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 toregistration_ip).0.0.0.0is 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 includesauto_ip_bannedandauto_ipfields; 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/bannow displays small clickable badges for every member whoselast_iporregistration_ipmatches 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 (oneWHERE 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 withTranslator::trans('profile.view.reactedWith')and adding surrounding spaces. The new keyprofile.view.reactedWithhas 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 carriesdata-number-slug, soclosest()returned the button instead of the parentdiv.list-group-item. Only the button was removed; the discussion row remained. Fixed by targetingbtn.closest('.list-group-item')instead.
Files changed:app/Views/users/profile.php.Anonymous visitor tracking — private discussion URL leaked in presence panel —
trackVisitor()runs inRouter::handleRequest()before route dispatch, so an anonymous visitor hitting/d/{number}-slugof 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 cachedDiscussion::findByGlobalNumber()+Category::find()) and, ifallowed_groupsis 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_atcolumn missing frombanstable —createGeneric()andupdateGeneric()automatically inject anupdated_attimestamp into every write, but thebanstable schema did not declare this column, causing aSQLSTATE[HY000]: General error: 1 table bans has no column named updated_aton every ban creation attempt. Addedupdated_at INTEGER NOT NULL DEFAULT 0to theCREATE TABLE bansdefinition, an inline migration so existing databases receive the column automatically on next startup, and incrementedSCHEMA_VERSIONto 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()andAvatarHelper::render()which are the only code paths that checkBan::isBanned(). Fixed by adding a ban check in every inline avatar-building block across all themes: post threads now override$avatarUrlwhen the author is banned, and thegetAvatarUrlWithGravatar()/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 strings —
window.Translationswas defined by an inline<script>block insidefooter.php(viatranslations.php). Page-specific JS modules likereports-management.jsare loaded via<script>tags (nodefer) at the end of the view file, which the browser executes immediately — before the footer is rendered. As a result,window.Translationswasundefinedwhen those modules initialized theirTRANSLATIONSconstants, causingUIHelpers.trans()to always return the hardcoded French fallback values. Fixed by movingtranslations.phpfromfooter.phptoheader.php(inside<head>) sowindow.Translations,window.__andwindow.urlare 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.jsmoves every.modalelement 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 duplicateid="createBackupModal"elements that broke Bootstrap's modal instance lookup; (2)BackupsManageris instantiated once atDOMContentLoadedand 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 plainwindow.location.reload()inBackupUI.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$usernamewhen user is banned — WhenBan::isBanned()returnedtrue, therender()method skipped theelsebranch where$usernamewas assigned and proceeded to use it uninitialized in thedot,detailed, andsimpleswitch cases (lines 300, 311, 336, 350, 355), generatingUndefined variable $usernamewarnings on the user list (/users) and profile pages. Moved$usernameand$sanitizedUsernameassignments before the ban check so they are always defined.
Files changed:app/Helpers/AvatarHelper.php.Router —
HEADrequests not matched againstGETroutes (RFC 7231) — The router only looked up routes by exact HTTP method, soHEAD /feed/rss(issued by RSS bots after following a FlatSEO 301 redirect) never matched theGET /feed/rssroute 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.svgandlogo.png— The terminal theme'sassets/img/directory only contained anindex.htmlplaceholder, causing 404 log entries on every page load when the terminal theme was active. Copied the default theme'sfavicon.svgandlogo.pngas defaults.
Files changed:themes/terminal/assets/img/favicon.svg,themes/terminal/assets/img/logo.png.Missing translation key
errors.validation.invalid.urlon Reports admin page — The Reports administration page displayed the raw keyerrors.validation.invalid.urlinstead of a human-readable string. The key existed inerrors.jsonunder theerrorsdomain but was referenced via theadmindomain translator, where it was absent. Addedinvalid.urlundererrors.validationin all fiveadmin.jsonlanguage 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 callingDiscussion::delete()directly) using SQLite storage,SqliteStorage::deleteDiscussion()only deleted thediscussionsrow and expected the caller to pre-delete the posts. Paths that skipped this step left orphaned rows in thepoststable. 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 aDELETE FROM posts WHERE discussion_idinsideSqliteStorage::deleteDiscussion()before removing the discussion row, making the cleanup unconditional. Also fixedFlatModerationExtend's two bulk-delete paths to correctly decrement the categorydiscussions_countandposts_countcounters 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.jsonreads per request —getThemeSetting()is called multiple times per page render (avatar border radius, show avatars, hero content, etc.), each call previously readingtheme.jsonfrom disk viaAtomicFileHelper. Added a static in-memory cache ($dataCache) toloadThemeData()so the file is read once per request and subsequent calls are served from memory. Cache is invalidated automatically insaveThemeData()after a successful write. This eliminates the repeated I/O that caused thetheme.setting.valuehook 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_atinside the service, but the underlying storage methods (getAllDiscussions,getDiscussionsByCategory) first sort byupdated_atand 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 andcreated_atsorting happen before pagination. For the global feed (/rss,/atom),getAllDiscussionsSorted('newest')is used, which sorts bycreated_at DESCat 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
ErrorDocumentfallback routes — Added routes for/403.shtml,/404.shtml, and/500.shtmlin the core router. When a server is configured withErrorDocument 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.comandscope=...https://www.googleapis.com...in the query string (standard Google OIDC parameters). Servers running mod_security or similar WAF rules that blockhttps://patterns in the query string would return 403 before PHP ran. The Google provider now usesresponse_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;MessageDateis now validated and auto-converted from non-RFC formats; crash fix whenMailerproperty is empty;Encodingvalidation 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 usedupdated_at, causing Thunderbird and other Atom readers to treat the entry as freshly updated and mark it unread again. The feed is now sorted bycreated_atso old discussions never bubble up, and Atom<updated>usescreated_atso 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) usesgroup-icon-badge size-lgfor the author avatar overlay, but the terminal theme CSS only definedsize-xs,size-sm, andsize-md. The badge rendered with no dimensions and thetop/leftoffset was absent, making it invisible. Addedsize-lgrules for bothgroup-icon-badge(20×20 px, offset −4 px) andavatar-statusto 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
.cardwrapper inbackend.csshadoverflow: hidden, which clips absolutely-positioned descendants. The approve/reject/delete options were in the DOM but visually hidden. Fixed by changing.cardtooverflow: visible. Also removed thetransform: translateY(-2px)lift effect from.card:hoverand.admin-card:hover(retained on.btn:hoverand.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.jsonvariables (primary_color,bg_primary,bg_secondary,bg_tertiary,text_primary,text_secondary,border_colorand theirdark_counterparts). Zero border-radius, no shadows, always-black navbar. Includes complete, self-sufficientbackend.cssfor the admin panel. Available in all 5 languages.
Files changed:themes/terminal/.
Fixed
/userspage crashes with fatal error for guests without permission —UserController::index()called$this->abort(403), a method that does not exist on the baseControllerclass, causing an uncaughtErroron every visit when theprofile.viewandpresence.viewpermissions 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 todate('r')(current wall-clock time), so the feed always appeared modified on every fetch regardless of content changes. Fixed by usingmax(created_at)of the fetched discussions, matching the existing Atom<updated>behaviour. As a bonus, the Atom<updated>per-entry now usesupdated_atwhen available (falling back tocreated_at), so edited discussions report the correct modification time. Reported by arpinux.
Files changed:app/Services/RssService.php.