Managers
Authorization (AuthZ)
The AuthZ service (lib/AuthZ.php) centralizes all authorization checks. Permissions use resource.action notation (e.g., posts.edit, threads.delete_own):
$authz = new AuthZ($pdo);
// Check if user has a permission
$authz->can($userId, 'posts.edit');
// Check with ownership (uses "permission_own" variant for owners)
$authz->canOnOwned($userId, 'posts.edit', $ownerId);
// Get user role
$authz->getUserRole($userId);
// Get role permissions
$authz->getRolePermissions('moderator');
// Check if user has a specific role
$authz->hasRole($userId, 'admin');
The admin middleware and can:permission middleware in src/Router.php both delegate to AuthZ, so admin routes require admin.access and can:foo.bar routes require the foo.bar permission.
Canonical Permission Registry
| Permission | Description |
|---|---|
admin.access |
Access admin panel |
threads.create |
Create new threads |
threads.edit / threads.edit_own |
Edit any / own thread |
threads.delete / threads.delete_own |
Delete any / own thread |
threads.lock |
Lock/unlock threads |
threads.sticky |
Sticky/unsticky threads |
threads.approve |
Approve pending threads |
threads.move |
Move thread to another category |
threads.split / threads.merge |
Split/merge threads |
threads.copy |
Copy thread |
posts.create |
Create replies |
posts.edit / posts.edit_own |
Edit any / own post |
posts.delete / posts.delete_own |
Delete any / own post |
users.create / users.edit / users.delete |
User management |
users.ban / users.suspend |
Ban/suspend users |
roles.manage |
Manage roles and permissions |
categories.manage |
Category management |
settings.manage |
Modify site settings |
plugins.manage |
Plugin management |
themes.manage |
Theme management |
langs.manage |
Language file management |
Plugin Manager (admin_plugins)
The Plugin Manager lists all discovered plugins, shows their metadata (name, version, author, description), and allows enabling/disabling them from the admin panel.
- Plugins are PHP files or folders in
plugins/ - Metadata is parsed from the file header:
/** * Plugin Name: MyPlugin * Version: 1.0.0 * Author: Developer * Description: Example plugin */ function myplugin_init() { // your code } - Plugin state is stored in
data/plugins.json - Install plugins directly from the dashboard by uploading a ZIP file containing one or more PHP plugin files
- Delete plugins directly from the dashboard
Hook System
Plugins can register callbacks that run when the core fires specific events. Three hook types are available:
- Actions (
runHook): Side effects only. All callbacks fire in priority order. - Filters (
filter): Transform a value through a chain of callbacks. - Checks (
checkHook/checkHookAll): Permission/veto gates.
function myplugin_init() {
global $pluginManager;
// Action: react to new posts
$pluginManager->addHook('post_after_create', function($postId, $data, $thread) {
// send notification, update stats, ...
});
// Filter: modify thread data before save
$pluginManager->addHook('thread_before_create', function(array $data): array {
$data['title'] = trim($data['title']);
return $data;
});
// Check: veto deletion
$pluginManager->addHook('thread_delete_block', function(array $thread): bool {
return $thread['reply_count'] > 100; // prevent deletion of popular threads
});
}
Hook Priority
Lower priority number = earlier execution. Default is 10:
$pluginManager->addHook('post_after_create', $callback, 5); // runs first
$pluginManager->addHook('post_after_create', $callback, 15); // runs later
Core Events Currently Wired
CRUD — Threads: thread_before_create, thread_after_create, thread_create_block, thread_before_update, thread_after_update, thread_before_delete, thread_after_delete, thread_delete_block
CRUD — Posts: post_before_create, post_after_create, post_create_block, post_before_update, post_after_update, post_before_delete, post_after_delete, post_delete_block
Rendering: thread_before_view, thread_posts_before_view, thread_before_render, thread_after_render, thread_not_found, before_render, frontend_before_render, admin_before_render, admin_sidebar_items, footer_before_render, render_content
Auth: auth_before_verify, auth_login_block, auth_after_login, auth_login_failed
Permissions: permission_{name} (dynamic, e.g. permission_posts.edit)
Users: user_registered
Plugin lifecycle: plugin_installed, plugin_updating, plugin_updated, plugin_enabled, plugin_disabled, plugin_auto_disabled, plugin_uninstalling, plugin_uninstalled, plugin_load_failed
See Plugin Development for argument signatures and ordering.
Theme Manager (admin_themes)
The Theme Manager discovers all themes in themes/, tracks the active theme, and provides CSS URLs/paths.
- Themes are subdirectories in
themes/containing astyle.css - Optional
manifest.jsonfor metadata:{ "name": "My Theme", "version": "1.0.0", "author": "Author Name", "description": "Theme description" } - Theme state is stored in
data/themes.json - Switch themes from Admin Panel → Themes
- Install themes directly from the dashboard by uploading a ZIP file containing a folder with
style.cssand optionalmanifest.json - Delete themes directly from the dashboard (default theme is protected)
Language Manager (admin_langs)
The Language Manager lets you upload and delete localization JSON files from the dashboard, and keeps them in sync with a static language mirror.
- Upload a file by choosing a language code (e.g.
fr) and selecting a JSON file that maps keys to translated strings - Files are saved to
lang/{code}.json - Delete any language file except the default one
- Language files are automatically picked up by the translation system
- Local change tracking is stored in
data/lang-meta.json(per-languagesha+updatedtimestamp) - The manager reads the list of available languages from a static mirror (
{update_mirror}/langs.json, defaulthttps://extend.bulletinbored.net/langs.json) and can pull updated language files when their checksum changes. Override the mirror with theupdate_mirrorsetting inconfig.json.
Update Manager (admin_updates)
The UpdateManager tracks installed versions of the core, plugins, and themes, and can apply updates. Before any update it runs preflight checks (PHP version, disk space, writability).
Architecture
The update system is split across three classes:
| Class | File | Responsibility |
|---|---|---|
UpdateManager |
lib/UpdateManager.php |
Orchestration: version tracking, checkAll, applyUpdate |
UpdateFetcher |
lib/UpdateFetcher.php |
HTTP requests, caching, remote version fetching |
UpdateBackup |
lib/UpdateBackup.php |
Core backup/restore, recursive copy/delete |
UpdateManager delegates HTTP/cache operations to UpdateFetcher and backup operations to UpdateBackup.
API
// Version tracking
$updateManager->setVersion('plugin', 'myplugin', '1.2.0');
$updateManager->getVersion('plugin', 'myplugin');
$updateManager->recordCheck('plugin', 'myplugin', '1.2.0');
// Check for updates
$results = $updateManager->checkAll($coreVersion, $pluginManager, $themeManager, $catalog);
// Apply updates
$updateManager->applyCoreUpdate('v1.0.0');
$updateManager->applyExtensionUpdate('plugin', 'myplugin', 'v1.2.0');
$updateManager->applyUpdate('plugin', 'myplugin', '/path/to/file.zip');
// Preflight checks
$errors = $updateManager->preflight('core', '1.0.0');
// Backup & recovery
$updateManager->backupCore();
$updateManager->restoreCoreBackup($path);
$updateManager->listBackups();
// Remote version (direct)
$version = $updateManager->getRemoteVersion('plugin', 'myplugin', $repoUrl);
Preflight Checks
$errors = $updateManager->preflight('core', '1.0.0');
// Returns array of error messages (empty = all clear)
// Checks: PHP >= 8.1, disk space >= 50MB, root writable, config writable
Backup & Recovery
backupCore()— creates a timestamped backup indata/backups/core_*restoreCoreBackup($path)— restores from a backuplistBackups()— lists available backups (keeps last 3)- Failed core updates automatically restore the backup
Version tracking
- Version tracking is stored in
data/updates.json - Core version is loaded dynamically from the
VERSIONfile viaconfig.json - Remote update checks require setting
"update_server"inconfig.json
Core updates
If update_server points to a GitHub repository, the Update Manager uses the GitHub Releases API to discover the latest version automatically. No versions.json is required.
Plugin and theme updates
Plugins and themes are checked against the repositories listed in data/catalog.json. If a catalog entry includes a repo URL pointing to GitHub, the Update Manager queries the GitHub Releases API for the latest tag.
Each catalog entry can carry two flags:
official(true/false) — whether the component is curated in the official catalog. In catalog-only mode ($config['allow_catalog_only'] = true) onlyofficial: trueentries may be installed.author_type("first_party"/"third_party") — distinguishes plugins/themes developed by the bulletinbored team from community-developed ones that are still curated into the official catalog. It is purely descriptive: the catalog UI shows “Developed by bulletinbored team” / “Developed by third party” instead of a generic “official” label, and it does not affect theofficialinstall gate.
GitHub token
GitHub API has a rate limit of 60 requests/hour for unauthenticated requests. You can provide a github_token in config.json to raise the limit to 5000 requests/hour. See Configuration for details.
Legacy update server
If update_server is not a GitHub URL, the Update Manager falls back to fetching versions.json from that server:
{
"core": {"version": "1.1.0"},
"plugins": {
"hellobored": {"version": "1.0.0", "url": "https://example.com/plugins/hellobored.zip"}
},
"themes": {
"default": {"version": "1.1.0", "url": "https://example.com/themes/default.zip"}
}
}
Applying updates
- Core updates are downloaded automatically from GitHub releases and extracted into the forum root.
- Plugin and theme updates can be downloaded automatically from GitHub if a
repoURL is defined incatalog.json. - Installing an extension from a repository (catalog or
installFromRepo()) uses the same pipeline as a ZIP install: the package is downloaded into a staging directory and validated (manifest plus core/PHP constraints and, when enabled, thefilesintegrity list) before the existing directory is moved aside and the staged package swapped in. Reinstalling over an existing plugin also runsplugin_updated/on_updateand rolls back files plus theinstalled.jsonrecord on failure. - ZIP upload via the admin panel is supported for plugin/theme updates.
- After a successful update, the version recorded in
data/updates.jsonis read from the updated package’smanifest.json; the package files themselves are not rewritten.
applyUpdate() behavior
applyUpdate() dispatches to different strategies based on the $type parameter:
plugin/theme— Delegates to the manager that owns the package lifecycle:plugin→PluginManager::updateFromZip($name, $zipPath)theme→ThemeManager::updateFromZip($name, $zipPath)
Both use the same rollback-safe pipeline:
- Runs preflight checks (disk space, writability)
- Moves the existing extension directory to
_old_{name}_{uniqid}(if present) - Extracts the ZIP and verifies the manifest plus, when enabled, the
filesintegrity list - For plugins, runs
plugin_updatedand<key>_on_update()before the backup is committed - Commits by moving the staged package into place
- On any failure: deletes the staged package, restores the
_old_*backup, restores the previousinstalled.jsonrecord, and returns a failure
Because the Update Manager calls straight into
PluginManager/ThemeManager, “Update All” and the admin panel share one pipeline: the same manifest/core/PHP constraint checks, file-integrity verification, lifecycle hooks (on_update) and rollback guarantees apply no matter how the update was triggered. TheverifyConfigKeyused matches the per-type admin toggle (plugin_verify_files/theme_verify_files).Rollback is filesystem-only. If a lifecycle hook fails, the previous files and
installed.jsonrecord are restored, but database changes the hook already performed are not automatically reverted. Plugin authors should keep migrations idempotent or provide their own rollback.core— Extracts into the forum root (legacy behavior for manual core ZIP uploads)