Skip to content

Releases: bulletinbored/bulletinbored-core

0.9.6

Choose a tag to compare

@mlzog mlzog released this 27 Sep 14:37

Release Notes

0.9.6

Added

  • New admin_sidebar_items hook, emitted inside the Extensions group of the admin sidebar (views/admin_header.php). Plugins can use it to add their own menu entries by echoing <li> items that match the existing sidebar markup. The hook takes no arguments and only fires for administrators on admin pages.

Changed

  • Repository installs now use the same pipeline as ZIP installs. installFromRepo() for plugins and themes downloads the package into a staging directory, runs the same package validation (verifyExtractedPackage() / verifyInstalledFiles()), and only then commits it through the shared backup → recordInstalled → lifecycle hooks → rollback flow. Reinstalling over an existing plugin also runs plugin_updated / on_update and rolls back both files and installed.json on failure.
  • UpdateManager accepts an optional root directory override, so its extension update path can be exercised in isolated integration tests.

Fixed

  • Repository install no longer removes the previously installed plugin/theme before the replacement has been downloaded, extracted and validated.
  • The file_get_contents() download fallback in repo_install.php now sets verify_peer / verify_peer_name explicitly (and disallows self-signed certificates), matching the cURL branch instead of silently relying on PHP defaults.
  • Added integration tests covering UpdateManager → PluginManager / ThemeManager delegation, version tracking and rollback when on_update fails or a theme package is invalid.
  • Added an end-to-end installFromRepo() test (local fixture, no network) that reinstalls a plugin over an existing one with a failing on_update() and asserts the previous files, manifest.json and installed.json record are restored with no leftover backup or staging directory. PluginManager::fetchRepoPackage() is a small protected seam so this path is testable without git or a remote.

0.9.5

Choose a tag to compare

@mlzog mlzog released this 27 Sep 12:51

Release Notes

0.9.5

Discussion navigation & scroll behaviour

  • Opening a discussion by its title now consistently lands on the opening post (page 1). Previously the viewport could sometimes jump to the bottom: the editbored reply editor auto-focuses on load and dragged the page down, and the corrective scroll used behavior: 'auto', which follows html { scroll-behavior: smooth } and therefore animated back to the top (visible down-then-up flicker). The thread view now neutralises the editor's focus-induced scroll and pins to the top with a genuinely instant scroll; anchor navigation is left intact. (views/thread.php)
  • The "last activity" excerpt link in the discussion list now reliably reaches the last post. It derives the correct post_page from ceil(reply_count / POSTS_PER_PAGE) before appending the #post-<id> anchor, so it no longer depends on the last post happening to be on page 1. (views/partials/thread_list.php)
  • Single source of truth for the posts-per-page size. Added a POSTS_PER_PAGE (15) constant, now used by both the thread query and the pagination maths so they cannot drift apart. (src/helpers.php, src/actions/posts-thread.php)

Fixes

  • Sidebar category counts no longer always show 0. The sidebar reads a thread_count value that sidebar_categories() never provided (SELECT * FROM categories has no such column), so every category displayed 0. The query now attaches a per-category count using the same visible statuses as the thread listing (visible, sticky, locked), so the number matches the category page. (src/Helpers/Data.php)
  • render_site_name() no longer applies ucfirst() to the site name; the configured name is rendered as-is. Previously the capitalisation was skipped only for CJK names. (src/Helpers/Text.php)

0.9.4

Choose a tag to compare

@mlzog mlzog released this 26 Sep 14:44

Release Notes

0.9.4

Security/hardening, a consistent update pipeline, installer UX and test/doc housekeeping. No database schema changes beyond the idempotent hardening of the existing foreign-key migration.

Security & hardening

  • data/ is now denied at the web root too. In addition to data/.htaccess (Apache), nginx.conf (location ^~ /data/), web.config (<hiddenSegments data>) and router.php, the root .htaccess now blocks /data/ via both RewriteRule and RedirectMatch. Defense-in-depth so the protection no longer depends on a single subdirectory file.
  • Package names are validated. Plugin/theme names taken from external input (expectedName, name) are checked against ^[a-z0-9][a-z0-9_-]*$ before they are used to build a path under plugins/ or themes/.

Repository / package installs are non-destructive

  • PluginManager::installFromRepo() and ThemeManager::installFromRepo() no longer delete the existing installation before downloading. The current directory is moved to _old_<name>_<uniqid>, the new package is downloaded/extracted/validated, the backup is deleted on success, and restored on any failure (including a failed download or an invalid package).
  • Fixed ThemeManager::installFromRepo() calling a non-existent $this->deleteDir() method on the failure/overwrite path.

Update Manager unified with the package managers

  • UpdateManager::applyExtensionUpdateFromZip() now delegates instead of running its own simplified pipeline:
    • plugin → PluginManager::updateFromZip()
    • theme → new ThemeManager::updateFromZip()
  • As a result, the admin "Update All" button and manual updates share one path with the same backup, manifest/core/PHP constraint checks, files integrity verification, lifecycle hook (plugin_updated / on_update) execution, installed.json handling and rollback guarantees.
  • applyUpdate() normalises the $type value (plugins/themes → singular), so admin ZIP uploads for extensions no longer fall through to the core path.
  • The version recorded in data/updates.json is now read from the updated package's manifest.json; removed the old detectVersionFromPackage() / syncVersionMetadata() helpers that rewrote package files.
  • Fixed a theme install/update bug: the verify callback returned ['success' => true, 'message' => 'ok'], which PackageInstaller::install() treated as an abort — the staged package was deleted while the operation was reported as successful. The callback now maps success to null.

MySQL foreign-key migration

  • migrations/20260904_add_foreign_keys.php is now idempotent and self-healing: constraints that already exist are skipped (so a re-run completes a previous partial run), and if an ALTER TABLE fails, every constraint created during that run is dropped before the error is rethrown. SET FOREIGN_KEY_CHECKS = 1 is always restored in finally.
  • down() no longer uses DROP FOREIGN KEY IF EXISTS (invalid on MySQL) and drops only constraints that actually exist.
  • Corrected the migration header comment, which previously over-claimed atomicity that MySQL DDL cannot provide.

Installer

  • Added a filesystem preflight with actionable error messages instead of the generic "check the server error log": web root, data/, and the SQLite database directory must be creatable/writable; a failed config.json write is now reported explicitly.
  • PDOException now produces a distinct "Database error…" message.

Admin diagnostics

  • The Diagnostics page now shows display_errors and expose_php (with a warning when they are On) and adds a recommendation to turn them off in production.

Packaging

  • Stopped tracking plugins/hellobored as a gitlink: it was recorded in the index as a submodule (mode 160000) with no .gitmodules, so any clean checkout/export produced an empty plugins/hellobored directory while the actual files were untracked. The stray entry was removed from the index (the plugin is distributed via its own repository/catalog, not bundled).
  • Aligned the core entry in data/catalog.json from 0.2.4 to 0.9.4.

Plugin sort hooks

  • thread_sort_options and thread_order_by filters in src/Helpers/Data.php, letting plugins add and implement their own thread sort options (e.g. a "Votes" sort).

Tests

  • New regression tests:
    • test_plugin_repo_install_preserves_existing_on_failure
    • test_theme_repo_install_preserves_existing_on_failure
    • test_theme_update_from_zip_replaces_and_cleans_backup
    • test_theme_update_from_zip_rolls_back_on_failure
    • test_plugin_lifecycle_on_update_runs_hook
  • tests/run.php argument parsing is now portable on Windows (tests\run.php is no longer treated as a filter).
  • Renamed the harness.php doc example (test_foo → my_test) so it no longer looks like an unregistered test.

0.9.3

Choose a tag to compare

@mlzog mlzog released this 26 Sep 11:49

Release Notes

0.9.3

  • Update Manager: added an "Update All" button to apply all available updates (plugins, themes and core) with a single click.
  • Updates are applied sequentially with core last, so a core failure does not block already-updated plugins and themes.

0.9.2

Choose a tag to compare

@mlzog mlzog released this 25 Sep 16:26

catalog bug fixed

0.9.1

Choose a tag to compare

@mlzog mlzog released this 25 Sep 15:34

Minor bug fixes

0.9.0

Choose a tag to compare

@mlzog mlzog released this 25 Sep 14:59

Release Notes

Version: 0.9.0

Summary

A large hardening + correctness release. It adds rate limiting to nearly every state-changing action, closes several information-disclosure and authorization gaps, wires up mention/reply notifications (in-app + email), fixes the "latest posts" ordering bug, hardens the installer and the shipped server configurations, and reworks the test suite to be behavioural rather than static.

Highlights:

  • Direct access to config.json, config.php, bb.php and router.php is now blocked on Apache, Nginx, IIS and the PHP built-in server.
  • The installer can no longer be re-run, abused to write outside the app directory, or CSRF'd; its re-run guard no longer fatal-errors.
  • @mentions now create in-app notifications and send email; replies can also email recipients.
  • The "latest / last activity" listing is deterministic and no longer shows a stale or duplicated last post after a reply.
  • File-based rate limiting extended from auth + posting to edits, deletes, uploads, profile/avatar changes, watch/unwatch and all admin operations.

Security Fixes

Direct file access (P0)

  • config.json / config.php were publicly readable. A plain GET /config.json returned DB credentials, SMTP settings and update-server
    configuration on Apache and Nginx. Now denied on every supported server:
    • Apache: new rewrite rule + defence-in-depth <FilesMatch> in .htaccess.
    • Nginx: new location block in nginx.conf.
    • IIS: new rewrite rule in web.config.
    • PHP built-in server: router.php now only serves allow-listed static
      asset extensions and refuses to expose secrets.

CLI and dev entrypoints

  • bb.php is CLI-only again. It now exits with HTTP 403 when reached over the web (PHP_SAPI !== 'cli'), instead of running with undefined $argv.
  • router.php no longer serves the data/ or uploads/private/ directories in development, matching production.

Installer (install.php, install2.php, install3.php)

  • Re-run guard fixed. The guard called log_security_event(), which was never loaded in the installer context — an already-installed forum returned HTTP 500 instead of the intended 403, and the installer_access_denied audit event was never written. The installers now require src/Security.php.
  • is_installed() fails closed. It no longer returns "not installed" when the database is temporarily unreachable; the presence of a config.json with a db_driver is authoritative, so a DB error can no longer re-open the installer.
  • CSRF protection added to all three installer steps (hidden token + validation on POST); previously the pre-auth installer forms had none.
  • Arbitrary filesystem write removed. The db_path POST value is now confined to the application directory before any mkdir()/SQLite file creation, closing a pre-auth mkdir -p/file-create primitive.
  • Error output no longer leaks PDOException messages; failures are logged server-side and show a generic message.

Content and downloads

  • Content-Disposition header injection fixed. The download handler no longer uses addslashes() on the user-supplied filename; control characters (\x00-\x1F, \x7F, CR/LF) are stripped and an RFC 5987 filename*=UTF-8'' form is emitted.
  • Orphan uploads return 404 for everyone. Previously any authenticated user could download an upload that had no associated thread. Orphan uploads (and uploads whose thread cannot be viewed) now raise NotFoundException, avoiding both access and existence disclosure.

Headers

  • Added Permissions-Policy on every response.
  • Added Strict-Transport-Security (HSTS) — emitted only over a real HTTPS connection.

Admin API

  • api/install.php was non-functional (never loaded PluginManager / ThemeManager → fatal "Class not found"). It now loads the managers and validates the user-supplied tag against a safe character set.

Open redirect / host injection

  • handle_watch() / handle_unwatch() no longer redirect to $_SERVER['HTTP_REFERER']; redirects are deterministic (url('thread', ...)).
  • Absolute URL generation (src/Helpers/Url.php) and the HTTPS redirect in src/bootstrap.php now strip control characters and slashes from the Host header.

Mail

  • send_email() validates mail_from with FILTER_VALIDATE_EMAIL and uses the validated value in the SMTP envelope.
  • SMTP STARTTLS now fails loudly (connection closed, false returned) instead of silently continuing unencrypted when stream_socket_enable_crypto()` fails.

Rate Limiting (new coverage)

The dependency-free file-based rate limiter (rate_limit()) is now applied to state-changing endpoints that previously had none:

Area Actions
Content edit_post, delete_post, edit_thread, delete_thread, watch, unwatch
User upload_image, edit_profile, remove_avatar
Admin settings (dashboard + SMTP), site image upload, catalog, categories (create/delete/reorder), languages, moderation (moderate/front-moderate/split/merge), plugins, themes, updates (check/apply), roles, user edit/create/delete/ban/unban/suspend

Limits are per-user where a session exists, otherwise per-IP, and fail-closed for sensitive actions.

Notifications

  • Mentions now notify. notify_mentioned_users() was dead code (never called and it only emailed). It now:
    • creates an in-app mention notification row, and
    • sends an email when the mentioned user has an address,
    • excludes the author, and
    • ignores foo@bar e-mail patterns (negative look-behind).
      It is invoked from both handle_reply_post() and handle_new_thread().
  • Reply notifications can email recipients. notify_thread_reply() still creates in-app rows for the thread author and all watchers, and now also emails them when an address is present. Disable with "email_notifications": false in config.json (default: enabled).
  • Private-message (pm) and vote (vote) notifications created by the textmebored and updownbored plugins continue to work; the bell display already maps all types.

Bug Fixes

  • "Latest posts" ordering. fetch_threads() determined the last post with MAX(created_at), which could match several posts written within the same second, producing duplicate rows / a non-deterministic last author and leaving a just-replied thread in the wrong position. Replaced with a correlated ORDER BY created_at DESC, id DESC LIMIT 1 join, and added id tie-breakers to every sort order.
  • Undefined variable in handle_watch() — $threadId was used in the CSRF-failure redirect before being computed.
  • Default avatars restored. The fallback avatar (initial in a coloured circle) was rendered with an inline style="..." attribute, which the
    tightened style-src (no 'unsafe-inline') blocked — leaving only the bare letter. It is now an inline <svg> (circle + text drawn with presentation attributes, unaffected by style-src). In addition, the CSP now allows style-src-attr 'unsafe-inline' so the remaining legitimate inline style attributes (admin icon circles, conditional display:none, logo sizing) keep working, while injected <style> blocks and non-allow-listed stylesheets stay blocked.
  • api/install.php fatal (see above).
  • Upload authorization ordering. handle_upload_image() now verifies the target thread/post exists and is viewable before moving the uploaded file, rejects mismatched post_id/thread_id, rate-limits uploads, and cleans up the file if the DB insert fails.

Database & Migrations

  • MySQL foreign-key migration safety (migrations/20260904_add_foreign_keys.php): SET FOREIGN_KEY_CHECKS = 0/1 is now wrapped in try/finally, so FK checks are always restored even if an ALTER TABLE fails. Note: MySQL DDL is still not transactional — a partial run must be re-run/reconciled.
  • SQLite rollback fixed: DROP INDEX IF EXISTS idx ON table (invalid SQLite syntax) corrected to DROP INDEX IF EXISTS idx.
  • Migrator::rollback() now acquires the migration lock before reading the last batch, removing a TOCTOU window between concurrent runners.
  • SQLite still does not enforce foreign keys at the DB level (by design); application-level checks and indexes remain in place.

Developer / Quality

  • Typed PDO wrapper: added return types to BbPdo::exec() / query() / prepare(); HttpException constructor accepts a nullable
    previous throwable (PHP 8.4 deprecation).
  • Test suite reworked to be behavioural. tests/SecurityFixesTest.php, tests/UploadSecurityTest.php, endpoint/download tests now execute the real handlers and assert runtime outcomes instead of grepping source. New shared tests/bootstrap.php, refreshed tests/harness.php and tests/run.php.
  • CI: PHP 8.5 added to the SQLite, MySQL and MariaDB matrices; the redundant duplicate full-suite run in the DB jobs was removed.
  • Documentation refreshed: route map/version references and the README.md dev-server command; the former repo-local docs/ inventory was merged into the external documentation site (docs.bulletinbored.net).
  • Nginx config no longer blocks install*.php unconditionally (that prevented fresh installs); the installer now self-guards via config.json.
  • Database regression tests for the two driver-sensitive queries: test_thread_listing_latest_join (correlated last-post join, verifies one row per thread with tied timestamps) and test_vote_upsert (atomic upsert with a unique index). Validated against SQLite and MariaDB 10.4 locally, and in CI.
  • Bootstrap integration tests (tests/BootstrapIntegrationTest.php): a subprocess smoke test that boots the real src/bootstrap.php (session, UTC timezone, autoloader, i18n) and optional HTTP tests (PHP built-in server + `rou...
Read more

0.8.13

Choose a tag to compare

@mlzog mlzog released this 10 Sep 15:53

Release Notes - v0.8.13

Summary

Release v0.8.13 addresses critical security vulnerabilities in thread watch/unwatch operations and improves the test suite's reliability.

Changes

Critical Security Fix

Problem: Users could add themselves as watchers to hidden/pending threads they couldn't view, enabling information leakage about thread existence.

Root Cause: handle_watch() and handle_unwatch() performed database mutations before verifying thread visibility.

Solution: Added authorization checks before any database mutation.

Files Modified:

  • src/actions/posts-thread.php
  • tests/EndpointAuthorizationTest.php

Changes in src/actions/posts-thread.php:

  • handle_watch(): Now selects thread status and calls can_view_thread() before INSERT. Throws ForbiddenException if user cannot view the thread.
  • handle_unwatch(): Now selects thread status and calls can_view_thread() before DELETE. Throws ForbiddenException if user cannot view the thread.

Impact:

  • Prevents information leakage about hidden/pending threads
  • Prevents unauthorized users from watching hidden threads
  • Prevents state mutation before authorization check

Test Suite Improvements

EndpointAuthorizationTest.php:

  • Fixed tests to use valid CSRF tokens instead of invalid ones that short-circuited the handler before reaching authorization logic
  • Tests now properly catch ForbiddenException to verify authorization failures
  • Added test_endpoint_moderator_unwatch_hidden_thread() to cover moderator positive case for unwatch
  • Renamed helper functions for clarity: setup_schema_endpoint(), create_user_endpoint(), create_post_endpoint()

Test Runner (tests/run.php):

  • Fixed argument parsing so --list and --verbose are not interpreted as test filters

Testing

All test files load and register correctly. The critical security fix ensures that hidden threads cannot be watched or unwatched by unauthorized users.

Affected Components

  • src/actions/posts-thread.php - handle_watch() and handle_unwatch() functions
  • tests/EndpointAuthorizationTest.php - Authorization matrix tests
  • tests/run.php - CLI test runner argument parsing

Backward Compatibility

  • No breaking changes introduced
  • Existing functionality preserved for visible/normal threads
  • Only restricts access to hidden threads (which was already implicitly restricted by authorization logic)

Security Impact

  • Prevents: Information disclosure of hidden threads via watch/unwatch operations
  • Prevents: State mutation before authorization checks
  • Maintains: All existing authorization rules for visible threads

0.8.12

Choose a tag to compare

@mlzog mlzog released this 07 Sep 17:40
  • Fixed installer bug

0.8.11

Choose a tag to compare

@mlzog mlzog released this 04 Sep 09:09

Security Fixes

Fix #1: Attachments in hidden threads publicly accessible

  • Upload attachments now stored in uploads/private/ instead of uploads/
  • New download endpoint /download/{id} with authorization checks
  • .htaccess in uploads/private/ denies all direct access (Apache)
  • nginx.conf blocks /uploads/private/ with location ^~ directive (Nginx)
  • Files can only be accessed through the authorized download endpoint
  • Affects: src/actions/posts-thread.php, src/actions/content.php, nginx.conf

Fix #2: SMTP Header Injection

  • send_email() now validates email addresses with FILTER_VALIDATE_EMAIL
  • CRLF injection prevention: rejects email headers containing \r or \n
  • Affected functions: src/Helpers/Mail.php

Fix #3: Reply to hidden thread - Authorization bypass

  • Added can_view_thread() check in handle_reply_post()
  • Users can no longer reply to hidden/pending threads
  • Affects: src/actions/posts-edit.php

Fix #4: Session invalidation on password change

  • Added session_version column to users table
  • is_logged_in() now verifies session version matches
  • Password reset increments version, invalidating old sessions
  • Important: Sessions created before this migration (lacking session_version) are now invalidated for security
  • New migration: 20260904_add_session_version.php
  • Affects: src/Helpers/AuthHelpers.php

Fix #5: Referrer-Policy too permissive

  • Changed from no-referrer-when-downgrade to strict-origin-when-cross-origin
  • Affects: src/csp.php

Fix #6: XSS in edit_post.php

  • Added escape() call on textarea content
  • Affects: views/edit_post.php

Fix #7: PluginManager hook processing

  • Changed continue to return false in hook callbacks
  • Ensures hooks don't accidentally skip subsequent handlers
  • Affects: lib/PluginManager.php

Fix #8: Rate limiter fail-closed for auth actions

  • Rate limiter now fails closed (denies) for login/register actions
  • Previously failed open, potentially allowing unlimited attempts
  • Affects: src/actions/users.php, src/Security.php

Fix #9: Migration race condition

  • getPending() called after acquiring lock, not before
  • Prevents race condition in concurrent migration scenarios
  • Affects: lib/Migrator.php

Fix #10: Database foreign keys

  • Added foreign key constraints via migration for MySQL
  • For SQLite: enables PRAGMA foreign_keys = ON and creates indexes (SQLite cannot add FK constraints via ALTER TABLE - table recreation would be required)
  • Added orphan record integrity check before applying FK constraints
  • Migration aborts with clear error message if orphans are found
  • New migration: 20260904_add_foreign_keys.php

Fix #11: Host header URL generation

  • Absolute URLs now use configured base_url instead of $_SERVER['HTTP_HOST']
  • Prevents host header injection via spoofed headers
  • Affects: src/Helpers/Url.php

Test Suite Improvements

New Test Framework (harness.php)

Assertions added:

  • assertThrows() - verifies callable throws an exception
  • assertNotThrows() - verifies callable does NOT throw
  • assertThat() - verifies with custom predicate

Test registration pattern:
Test files now use register_tests('test_foo', 'test_bar') instead of creating a local $suite and calling $suite->run(). This allows the test runner to load all test files and run them together.

New Test Files

  • tests/EmailSecurityTest.php - SMTP injection and email validation tests
  • tests/EndpointAuthorizationTest.php - HTTP endpoint authorization matrix
  • tests/SessionSecurityTest.php - Session invalidation tests
  • tests/UploadSecurityTest.php - Upload security and direct access prevention

Converted Test Files (27 files)

The following test files were converted to the new register_tests() pattern:

  1. tests/AuthTest.php
  2. tests/AuthHardeningTest.php
  3. tests/ContentCrudTest.php
  4. tests/ContentHardeningTest.php
  5. tests/DatabaseIntegrityTest.php
  6. tests/DatabaseMatrixTest.php
  7. tests/DbQueryTest.php
  8. tests/E2eFlowTest.php
  9. tests/E2eIntegrationTest.php
  10. tests/HelpersTest.php
  11. tests/InstallerTest.php
  12. tests/MarkdownTest.php
  13. tests/MigratorTest.php
  14. tests/ModerationHandlerTest.php
  15. tests/ModerationTest.php
  16. tests/PluginManagerTest.php
  17. tests/PluginRouterTest.php
  18. tests/PluginThemeTest.php
  19. tests/RegistrationTest.php
  20. tests/RendererTest.php
  21. tests/ResponseTest.php
  22. tests/SecurityHardeningTest.php
  23. tests/SecurityTest.php
  24. tests/SuggestedTest.php
  25. tests/UpdateFailureModeTest.php
  26. tests/UpdateManagerTest.php
  27. tests/UpgradeTest.php

Test Runner Improvements

  • tests/run.php now uses get_test_suite() global instead of local $suite
  • Added --list flag to list registered tests without running
  • Test files no longer call exit() - runner handles exit code

Database Migrations

Two new migrations added:

  1. migrations/20260904_add_session_version.php - Adds session_version column to users
  2. migrations/20260904_add_foreign_keys.php - Adds foreign key constraints