Generated by php flatbb api:list on 2026-09-30. Every function below is global and available to plugins.

core/ai.php

  • ai_setting_name(int $n, string $field): string — The setting that holds one field of connection $n: the first keeps the plain names (ai_provider…), the others ai2_…, ai3_….
  • ai_connections(): array — The connections that are set up, in the admin's order: n => [n, provider, base_url, model, key].
  • ai_ready(): bool — Whether a plugin can call ai_chat(): at least one connection is set up.
  • ai_state(): array — What the site remembers about its connections (data/cache/ai.json): per connection, the last answer, the last failure and a rest.
  • ai_state_update(callable $fn): void — Change the state under a lock: $fn receives it and returns it changed.
  • ai_order(array $conns): array — The order to try the connections in: the admin's order, the resting ones last (still tried when all others failed).
  • ai_chat(string $system, string $user, array $opts = []): array — One question to the model. $opts: purpose (the plugin id, for ai.request / ai.response), max_tokens (the longest answer you expect, default 800), temperature (default 0.2; OpenAI-compatible services only, current Claude models take none), json (true asks for a JSON object where the API supports it), connection (ask only that one, e.g. to test it). Returns ['ok' => true, 'text' => …, 'model' => …, 'connection' => n, 'usage' => ['in' => n, 'out' => n]] or ['ok' => false, 'error' => …] after every connection failed. Never call it from a loop or inside a transaction: it is an HTTPS request that may take seconds (more when it falls back).
  • ai_call(array $c, string $system, string $user, array $opts, int $timeout): array — Ask one connection. The answer as ai_chat() gives it, and on failure 'status' (HTTP code, 0: no answer) and 'rest' (its own fault).
  • ai_note(int $n, array $r): void — Remember how a connection did: the last answer, the last failure and, when the failure was its own, a rest.
  • ai_json(string $text): ?array — The first JSON object in an answer (models like to wrap it in ```json fences or a sentence), or null.
  • ai_http(string $url, array $headers, string $body, int $timeout): array — POST JSON over HTTPS with the certificate checked: [status, decoded body or null, error].

core/auth.php

  • secret(): string
  • uid(): int
  • me(): ?array — Current user row or null. Cached per request.
  • auth_signature(int $uid, int $exp, string $password_hash): string
  • login_pending_set(array $user, bool $remember): void — Pending login: the password was right but a plugin asked for a second step (account.login_challenge). The user id travels in a signed five-minute cookie; login_pending_user() gives the user back to the plugin that finishes the sign-in.
  • login_pending_user(): ?array — ['user' => row, 'remember' => bool] for a valid pending cookie, else null.
  • login_pending_clear(): void
  • auth_key(array $user): string — What a session signature is bound to: the password hash plus a per-user salt (changing either signs out every device).
  • user_logout_everywhere(int $id): void — Sign a user out on every device by rotating the salt; the password stays. Fires user.logout_everywhere.
  • login_user(array $user, bool $remember = true): void
  • logout_user(): void
  • visitor_token(): string — Visitor token cookie used as CSRF seed for guests and members alike.
  • csrf_token(): string
  • csrf_field(): string
  • check_csrf(): void — Verified once per request by dispatch() for every POST; handlers may still call it (no-op the second time).
  • require_post(): void — POST only + CSRF.
  • need_login(): array
  • need_admin(): array
  • need_mod(): array
  • groups(): array
  • group_by_id(int $id): ?array
  • my_group(): ?array
  • is_admin(): bool
  • is_mod(): bool
  • can(string $permission): bool — can('post'), can('reply'), can('upload'), can('edit_own'), can('delete_own'). The member's group decides, then plugins may change the answer through the filter user.can (ctx: permission, user, group): a trust level that allows links, a time-out that forbids replies. Guests have no permissions and admins always have every one; no plugin changes either.
  • group_allowed(string $csv, bool $guest_ok = true): bool — Group id list from a comma separated setting; empty means "everyone".
  • user_by_id(int $id): ?array — Full user row (all columns). Cached per request under 'users_full'.
  • user_by_name(string $name): ?array
  • users_cache_put(array $users, string $bucket = 'users'): void
  • user_public_columns(): string — Public user columns safe to render anywhere (no password/email).
  • user_level(array $user): int — A member's level as a number, 0 when no plugin keeps levels. The plugin that does answers the filter user.level (ctx: user); others (badges, gated topics) read it here instead of calling that plugin. Runs inside lists: the answer must not query.
  • users_by_ids(array $ids): array — Batch-load users (public columns) for a list of ids, keyed by id. Never call in a loop.
  • username_rule(): array — Username length rule from the settings: [min, max] (letters, numbers, dot, dash, underscore; the first character a letter or digit).
  • username_valid(string $name): bool
  • username_rule_text(): string — The message for a username that breaks the rule.
  • password_min(): int — Shortest password the settings allow.
  • password_check(string $pass): string — '' when the password is long enough, else the message.
  • user_rename(array $user, string $new, int $by = 0): string — Change a username: same rules as registration, the old name is kept so /u/<old name> redirects, caches are cleared and user.after_rename fires. Returns '' on success or the error message to show. $by is the acting user (admin or self).
  • display_names_on(): bool — Whether members may have a display name (Settings → Registration). While off, every name shows as the username.
  • display_name_clean(string $name): string — A display name as it is stored: invisible and control characters removed, spaces collapsed. '' clears it.
  • display_name_check(array $user, string $name): string — '' when $name (cleaned) may be the display name of $user, else why not: at most 30 characters, nobody else's username.
  • display_name_set(array $user, string $name): string — Set a member's display name ('' clears it; the same as the username clears it too). Returns '' or the error to show. The profile address, sign-in and @mentions keep using the username.
  • user_former_names(array $user): array — Previous usernames of a user, oldest first: [['name' => ..., 'at' => unix], ...].
  • user_by_former_name(string $name): ?array — The user who used to have this name (old profile links redirect to the current name).
  • user_create(string $username, string $email, string $password, int $group_id = 0): int
  • user_url(array|string $user): string
  • post_wait_seconds(array $user): int — Rate limit helper: returns seconds the user must still wait before posting.
  • post_hold(array $user): array — When this member may post again: ['until' => unix seconds, 'seconds' => how many are left, 'reason' => 'interval'|'new_user', 'message' => what to tell them]. An empty array means right now. Moderators are never held back.
  • human_wait(int $seconds): string — A waiting time in words: "12 seconds", "3 minutes", "2 hours 5 minutes".
  • new_user_limited(array $user): bool

core/boot.php

  • config(?string $key = null, mixed $default = null, ?array $override = null): mixed — Site configuration from data/config.php. Empty array before installation.
  • is_installed(): bool
  • debug_mode(): bool
  • app_boot(): void — Boot the application for a web request or CLI command.

core/cron.php

  • cron_jobs(): array
  • cron_interval(array $job): int
  • cron_run(bool $force = false): array — Run every due job. Returns [name => status].
  • cron_run_web(): never
  • cron_hot_scores(): string — Recompute hot_score for topics active in the last 30 days.
  • cron_cleanup(): string
  • cron_stats(): string
  • cron_update_check(): string — Daily: refresh the core release cache so the admin menu shows "Updates · new" without anyone clicking Check.

core/db.php

  • db(): PDO
  • db_driver(): string
  • db_is_mysql(): bool
  • db_connect(array $c): PDO — Open a connection from a config array. Used by db() and by the installer.
  • sql_query_count(bool $inc = false): int
  • q(string $sql, array $params = []): PDOStatement — Run a prepared query.
  • one(string $sql, array $params = []): ?array
  • all(string $sql, array $params = []): array
  • val(string $sql, array $params = []): mixed
  • col(string $sql, array $params = []): array — First column of every row.
  • tx(callable $fn): mixed — Run $fn inside a transaction; nested calls join the outer transaction.
  • sql_marks(int $count): string
  • rows_by_ids(string $table, array $ids, string $cols = '*', string $key = 'id'): array — Fetch rows by primary key, keyed by id. Chunks large id lists.
  • db_like(string $s): string — LIKE pattern for user input, to be used with LIKE ? ESCAPE '!' (a backslash escape character is a syntax error on MySQL).
  • db_insert(string $table, array $data): int
  • db_update(string $table, array $data, string $where, array $params = []): int
  • db_delete(string $table, string $where, array $params = []): int
  • db_upsert(string $table, array $data, array $keys): void — Insert or update on conflict of $keys (must be PK or UNIQUE).
  • db_insert_ignore(string $table, array $data): bool — Insert unless a unique key already exists. Returns true when inserted.
  • db_increment(string $table, string $column, int $delta, string $where, array $params = []): void — Atomic counter update: db_increment('fb_topics', 'view_count', 1, 'id=?', [$id])
  • db_types(): array — Portable column types. Use these names in db_create_table()/db_ensure_columns(): id, uint, int, bigint, bool, float, string (255), key (191, indexable), text, mediumtext.
  • db_column_sql(string $name, string $type): string
  • db_create_table(string $table, array $columns): void — Create a table if missing. $columns: ['id' => 'id', 'title' => 'string', 'body' => 'text', 'flag' => 'TINYINT NOT NULL DEFAULT 1']. Raw SQL types are allowed but must work on both engines.
  • db_drop_table(string $table): void
  • db_table_exists(string $table): bool
  • db_columns(string $table): array
  • db_ensure_columns(string $table, array $columns): void — Add any missing columns (idempotent).
  • db_drop_column(string $table, string $column): void
  • db_index_exists(string $table, string $index): bool
  • db_create_index(string $table, string $index, array $columns, bool $unique = false): void — db_create_index('fb_posts', 'idx_posts_topic', ['topic_id','id'])
  • db_drop_index(string $table, string $index): void
  • db_create_fulltext(string $table, string $index, array $columns): void — Full-text index for MySQL; SQLite uses FTS5 virtual tables (see search.php).
  • db_greatest(string $a, string $b): string — Portable "greatest of two expressions".
  • db_random(): string — Portable random ordering.

core/devtools.php

  • plugin_check(string $id): array — Static checks a plugin must pass before it is accepted by the marketplace.
  • shell_exec_safe(array $cmd): ?string
  • plugin_package(string $id): string — Zip plugins/<id>/ into dist/<id>-<version>.zip. Returns the zip path.
  • plugin_publish(string $id, string $token, string $changelog = '', string $endpoint = '', bool $insecure = false, array $images = [], bool $confirm_other = false): array — $images: screenshots to publish with the version, each ['path' => local file, 'name' => file name] (up to 5; they replace the plugin's set on the marketplace).
  • lang_keys(): array — Every t('...') source string in core/ and app/ (sorted).
  • lang_sync(string $code): array — Write lang/<code>.php: keeps existing translations, adds missing keys with an empty value, drops keys that no longer exist. Returns [added, removed, untranslated].
  • security_files(string $dir): array — PHP files under a directory (recursive).
  • security_scan(array $files): array — Rules every file must pass (core, app and plugins alike). Returns "file:line: message" findings. - no direct $_GET/$_POST/$_REQUEST/$_COOKIE reads (use post_str/post_int/post_list/post_secret/get_str/get_int) - no eval/exec/system/passthru/shell_exec/popen/proc_open/unserialize - in views: <?= ... ?> may only start with h(), t(), raw() or another known safe helper, never a bare variable
  • test_boot(): void — Minimal test runner, no dependencies: every tests/.php file defines test_ functions that throw on failure. test_boot() gives each run a fresh SQLite database in the system temp dir, so tests never touch data/.
  • test_assert(bool $ok, string $message = 'assertion failed'): void
  • test_same(mixed $expected, mixed $actual, string $what = 'value'): void
  • test_contains(string $needle, string $haystack, string $what = 'output'): void
  • test_not_contains(string $needle, string $haystack, string $what = 'output'): void
  • test_run(string $only = '', ?callable $out = null): array — Run every test_* function found in tests/*.php (or one file). Returns [passed, failed, [failures]].

core/helpers.php

  • raw(string $html): string — Marks HTML that is intentionally output unescaped (already-rendered fragments). security:check allows only raw() and known helpers after <?=
  • h(string|int|float|bool|null $s): string
  • now(): int
  • cut(string $s, int $max = 100, string $suffix = '…'): string — Truncate a UTF-8 string with an ellipsis.
  • human_time(int $ts): string — "3 minutes ago" style relative time.
  • tz_options(): array — Time zone choices for Settings → General: identifier => 'Region/City (UTC+03:00)'.
  • human_size(int $bytes): string
  • human_number(int $n): string
  • slugify(string $s, int $max = 80): string — URL slug from a title: "Hello World!" -> "hello-world".
  • json_decode_array(?string $json, array $default = []): array
  • json_encode_value(mixed $value): string
  • random_token(int $bytes = 16): string
  • client_ip(): string — The visitor's address; behind a trusted proxy (setting trusted_proxies) the forwarded address. See core/security.php.
  • is_post(): bool
  • is_ajax(): bool
  • post_str(string $key, int $max = 65535): string
  • post_secret(string $key, int $max = 4096): string — Untrimmed POST string (passwords).
  • post_list(string $key, int $max = 200): array — POST list of scalars as strings (checkbox groups, multi-selects).
  • post_int(string $key, int $default = 0): int
  • get_str(string $key, int $max = 500): string
  • get_int(string $key, int $default = 0, int $min = PHP_INT_MIN, int $max = PHP_INT_MAX): int
  • json_response(array $data, int $status = 200): never — Send JSON and stop.
  • json_ok(array $data = []): never
  • json_error(string $message, int $status = 400, array $extra = []): never
  • redirect(string $url, int $status = 302): never — Redirect and stop.
  • undo_keep(string $scope, string $value): void — The value a removal replaced, kept on the server for a minute so the toast's Undo can bring it back (field_action()). $scope names what was removed ("setting:site_favicon", "avatar:12"); the value never goes to the browser.
  • undo_take(string $scope): ?string — Take back what undo_keep() kept for this member and scope, once, within a minute; null when it is gone.
  • flash(string $message, string $type = 'success'): void — Flash message stored in a short-lived cookie (no server sessions).
  • flash_take(): ?array
  • cookie_str(string $name, int $max = 200): string — A cookie value, trimmed to $max characters ('' when absent).
  • app_cookie(string $name, string $value, int $expires, bool $httponly = true): void
  • is_https(): bool
  • error_page(string $message, int $status = 400, string $title = ''): never — Show an error page and stop.
  • not_found(string $message = ''): never
  • forbidden(string $message = ''): never
  • fail(string $message, string $back = ''): never — Abort POST handlers with a message; AJAX gets JSON, browsers get a flash + back.
  • request_cache(string $key, ?callable $fn = null, bool $reset = false): mixed — Per-request memo cache. request_cache('key', fn() => ...)
  • setting(string $key, string $default = ''): string — Read a site setting (fb_settings). All settings are cached per request.
  • settings_all(): array
  • setting_defaults(): array
  • save_settings(array $values): void
  • mail_send(string $to, string $subject, string $text): bool — Send a plain-text email. Plugins take over through the mail.send hook (return true when sent, false when failed); without a plugin PHP's mail() is used. Returns whether the mail was accepted.
  • http_exec_prefer_local(CurlHandle $ch, string $url): string|false — A request from this site to its own host (www.flatbb.com asking its own marketplace, the deployment self-check) goes to the web server on this machine directly: the host name is resolved to 127.0.0.1, so a CDN or proxy in front (Cloudflare challenges a server talking to itself) is not in the way. When nothing answers on the loopback, the normal route is used. $ch is configured and ready; returns what curl_exec() returns.
  • http_self_request_blocked(string $url): bool
  • paginate_calc(int $total, int $page, int $per_page): array — Simple ordered pagination info.

core/hook.php

  • hook_registry(?array $set = null): array
  • hook_add(string $name, callable|string $callback, string $plugin = '', int $priority = 10): void — Register a callback at runtime (core modules and plugins loaded from manifests).
  • hook_has(string $name): bool
  • hook(string $name, mixed $value = null, array $ctx = []): mixed
  • fire(string $name, array $ctx = []): void
  • regions_known(): array — Every layout region the core renders, with a short description (used by Admin → Widgets and docs).
  • region(string $name, array $ctx = [], string $default = '', bool $wrap = true): string — HTML region. $default is the core content (may be empty). $wrap=false returns raw HTML (for <head>, scripts).
  • region_visible(string $html): bool — Whether region HTML shows anything: hidden inputs, scripts and empty containers do not count. A template uses this before it draws a frame around a region ("More options" in the composer), so a plugin that only plants a hidden field or a script does not leave an empty box behind.
  • region_list(string $name, array $items, array $ctx = []): array — Array-valued region (nav links, tabs, cards, menu items). Items are keyed by id; besides label/url/icon/html an item may carry - weight (int, default 0): lower comes first, equal weights keep insertion order - visible ('everyone' default | 'members' | 'admins'): filtered here, once, for every plugin - new_tab (bool): links open in a new tab where the template supports it Admins can hide single items per region and put them in any order in Admin -> Widgets (settings layout_hidden_items, layout_item_order).
  • layout_order_items(string $region, array $items): array — Sort the items of a list region: the order an admin saved in Admin → Widgets comes first (in that order), then everything else by weight, then registration order. Plain HTML items count as weight 0.
  • layout_item_order(string $region): array — Item ids an admin ordered in one list region, first to last (setting layout_item_order: {region: [id, …]}).
  • layout_core_items(string $region): array — The items the core itself puts into a list region (label only), so Admin → Widgets can order them next to plugin items.
  • layout_hidden_items(string $region): array — Items an admin hid in one list region: id => 1 (setting layout_hidden_items: {region: {id: 1}}).
  • menus_known(): array — The list regions an admin edits as menus, region => name. Their items can get new text, a new link, an icon and a new tab, and the admin adds links of their own; hiding and order are the same settings Admin → Widgets writes. Plugins add a menu of their own through the filter menus.known.
  • menu_edits(string $region): array — The admin's edits of one menu, item id => [label, url, icon, new_tab, group, custom, weight] (setting menu_items: {region: {id: {…}}}).
  • menu_href(string $url): string — A link an admin typed: a path on this site ("/growth?tab=x") goes through url(), a full http(s) address stays, anything else is ''.
  • menu_apply(string $region, array $items): array — A menu with the admin's edits applied: new text, link, icon or tab for any item (core, plugin or custom), and the custom links added.
  • layout_blocks(): array — Admin-managed HTML blocks stored in setting layout_blocks: [{id,region,title,html,enabled,sort}]
  • layout_blocks_html(string $region, bool $wrap = true): string
  • layout_plugin_enabled(string $hook, string $plugin): bool — Admin can switch a plugin off in a specific region (setting layout_regions: {"region.x": {"plugin": 0}}).

core/icons.php

  • icon_set(): array — The built-in icons with what plugins add (filter icon.paths): name => SVG inner markup. Once per request.
  • icon_value_ok(string $v): bool — Whether a value is one an icon field may store.
  • icon_library(): array — The shared library of uploaded icons (uploads/site/icon_*), newest first: every icon field offers them under Your icons.
  • icon_library_add(array $file): string — Add an uploaded icon to the library (named by its content, so the same file is kept once; an SVG is cleaned). Returns its value.
  • icon_from_post(string $name, string $current = ''): string — The value an icon field posted: an uploaded file (field <name>_file) joins the library and wins, else the picked value when it is valid, else $current. Throws RuntimeException with a message for the admin when the upload is refused.
  • icon_picker(string $name, string $value, array $opts = []): string — The icon field of every form: a button with the current icon that opens a panel with a search box, the built-in icons, Your icons (the shared library), an emoji box and an upload (the form needs enctype="multipart/form-data"). Opts: keep => the label of a first tile that keeps what the item has (value ""; "none" then removes the icon), none => false to leave out "No icon", upload => false for no file input. app.js wires the tiles, the search and the emoji box ([data-icon-picker]).
  • icon_paths_more(): array

core/import.php

  • importers(): array — Importer plugins, installed or enabled: plugin id => ['plugin', 'from', 'label', 'description', 'page', 'enabled', 'name', 'version'].
  • importer(string $key): ?array — The importer for a source name ('flarum') or a plugin id.
  • import_job(): array — The current or last job ([] when none): plugin, status (running|done|failed|cancelled), phases, phase, cursor, data, secret, notes, ...
  • import_job_save(array $job): void
  • import_ready(): array — Whether this forum may receive an import: ['ok' => bool, 'reason' => text, 'members', 'topics', 'categories', 'tags']. A new forum has one member (you) and the starter content, which the import replaces. After an import, starting again is allowed too: the members and content of the last import are removed first.
  • import_start(string $plugin, array $phases, array $data = [], array $secret = []): void — Start a job for an importer plugin. $phases: [['key' => 'users', 'label' => 'Members', 'total' => 3412], ...] in the order they run (labels are English source text, shown through t()). $data is the importer's own state; $secret holds what must not outlive the job (a database password): it is removed when the job ends.
  • import_reset(int $keep_user = 0): void — Empty the content tables for an import: topics, posts, categories, tags, likes and everything hanging off them. $keep_user > 0 (starting again after an import) also removes every member except that one.
  • import_phase_next(array $job): array — Move a job to its next phase (importers return this from their step when the phase has no rows left).
  • import_note(string $text): void — A line for the report ("12 posts were empty and skipped"). Collected per batch and saved with the job.
  • import_run(float $seconds = 8.0): array — Run the job for about $seconds: batch after batch, each in one transaction with the saved job. Returns the job. Two callers at once (two tabs, the page and the command line) are kept apart by a lock: the second one just reads.
  • import_done(array $job): array
  • import_cancel(): void — Stop a job: what was imported stays, the secret goes. Starting again removes it and begins anew.
  • import_retry(): void — Pick up a failed job where it stopped.
  • import_phase_counts(array $job): array — Counters of everything that was imported, in a few set-wise statements.
  • import_phase_search(array $job): array — The search index, 500 posts a batch.
  • import_username(string $name, string $fallback): string — A username that follows the site's rule and is not taken: "Jane Doe" → "Jane_Doe", "_x" → "x", taken → "name_2".
  • import_slug(string $table, string $slug, string $fallback): string — A slug that is not taken in $table ('fb_categories', 'fb_tags').
  • import_user(array $u): int — Add a member. $u: username, email, password (a bcrypt/argon hash from the source, kept so members sign in as before), display_name, group_id, avatar_file (a picture on this server, stored as the avatar), bio, created_at, last_seen, status (0 banned), email_verified, old_id (for fallbacks). A member whose email is already here (you) is matched to that account instead. Returns the member's id.
  • import_group(string $name, string $color = ''): int — The id of a group by slug, created with the members' permissions when missing.
  • import_category(array $c): int — Add a category: name, slug, description, color, parent_id, sort, is_hidden, view_groups, post_groups. Returns its id.
  • import_tag(array $tag): int — Add a tag: name, slug. Returns its id (an existing tag with the same name is reused).
  • import_topic(array $t): int — Add a topic without its posts: id (optional, keeps the source id), category_id, user_id, title, created_at, is_pinned, is_locked, is_deleted (0, 1 deleted, REVIEW_PENDING waiting for approval), view_count, tags (tag ids). Its first post, last reply and counts come from import_post() and the Counters phase.
  • import_post(array $p): int — Add a post (Markdown body): id (optional), topic_id, user_id, floor (0 = the topic's first post), body, created_at, reply_to_id, edited_at, edited_by, is_deleted (0, 1, REVIEW_PENDING), created_ip. A waiting post also gets its row in the review queue. Returns the post id.
  • import_like(int $user_id, int $post_id, int $topic_id, int $at = 0): void — A like of a post.
  • import_read(int $user_id, int $topic_id, int $last_post_id, int $at = 0): void — How far a member has read a topic (keeps imported topics from all showing as unread).
  • import_copy_file(string $file, string $dir): string — Copy a file of the source forum into uploads/import/<dir>/ under a new name. Pictures only, plus common documents and archives; never anything a server could run. Returns its public URL ('' when the file is missing or refused).
  • import_cli(string $from, array $opts, callable $out): int — php flatbb import [from] [--options]: without a source, list the importers and the job. With one, continue its job, or start a new one through the importer's cli callback (its --options), then run it to the end. Returns the exit code.

core/lang.php

  • lang_code(): string
  • lang_site_code(): string — The administrator's default language (Settings → General), before any per-visitor choice.
  • lang_installed(string $code): bool
  • lang_set(string $code, ?array $prefs = null): void — Remember the language: a cookie for everyone, the preferences for a member. $prefs: the preferences the caller has just written. Settings → Preferences passes them so this does not save the row it read at the start of the request over the answers just given (theme, notifications, points would jump back). Without them the row is read again here, never from the request's cached copy.
  • lang_available(): array — Installed language packs: code => native name (English always first).
  • lang_direction(): string — 'rtl' when the active pack declares '__dir' => 'rtl' (Persian, Arabic, Hebrew…), else 'ltr'.
  • lang_table(): array
  • lang_add(array $strings): void — Merge a plugin's translations at runtime.
  • t(string $key, mixed ...$args): string
  • link_previews_on(): bool
  • link_hash(string $url): string — The key of a URL: trimmed, without its fragment.
  • link_previewable(string $url): bool — Whether a URL may get a card: http(s) to another site, not a picture, a video, a file or a video site (those have their own players), not on the blocked list. Filter link.previewable (bool; ctx url, host) lets a plugin take a link over.
  • link_standalone(string $html, int $max = 3): array — The links that stand in a paragraph of their own in rendered post HTML, in order, at most $max.
  • link_previews(array $urls, bool $queue = true): array — The stored previews of these URLs that are ready, url => row, in one query; the ones never seen are queued in one insert and fetched after this request (or by the scheduled job). Lists and topic pages call it once per page.
  • link_queue_post(string $html): void — Queue the stand-alone links of a post that was just written, so its cards are ready before anyone opens it.
  • link_fetch_soon(): void — After the response is sent (PHP-FPM), fetch what this request queued; elsewhere the scheduled job does it.
  • link_fetch_job(): string — Scheduled job core.link_previews: fetch pending links, retry failed ones (up to three tries a day apart), refresh old ones.
  • link_fetch_pending(int $limit): int
  • link_fetch(string $url): ?array — Fetch and read one page: ['title', 'description', 'image', 'site_name', 'card' large|small|text] or null.
  • link_public_ip(string $ip): bool — A public address: not loopback, private (RFC 1918, fc00::/7), link-local, CGNAT, reserved or IPv4 mapped in IPv6.
  • link_resolve(string $host): ?string — The one address to connect to for a host, when every address it resolves to is public; null otherwise.
  • link_http_get(string $url, int $hops = 0): ?array — GET a page with every check in the file header: ['url' => final url, 'type' => content type, 'body' => up to 512 KB] or null.
  • link_absolute(string $ref, string $base): string — An address from a page made absolute against the page's own; '' unless it ends up http(s).
  • link_parse(string $html, string $url, string $type = ''): ?array — Read the card from a page's HTML: Open Graph first, then Twitter cards, then the plain title and description.
  • link_card_html(array $p, string $url): string — The card: a link to the page with its picture (when it has one), its site, title and description. Everything escaped.
  • link_cards_in_posts(array $posts): array — The topic page: a link on a line of its own becomes its card (three per post), the cards of the whole page in one query.

core/manifest.php

  • plugin_manifest_parse(string $src, ?string &$error = null): ?array
  • plugin_manifest_value(array $toks, int &$p): mixed — One value at $p (advances $p past it).
  • plugin_manifest_atom(array $toks, int &$p): mixed
  • plugin_manifest_array(array $toks, int &$p, string $close): array — Array items up to the closing bracket (advances $p past it).
  • plugin_manifest_string(string $literal): string — The value of a single- or double-quoted string literal (double quotes with variables are not constant strings and never reach here).
  • plugin_manifest_at(array $toks, int $p): string

core/markdown.php

  • md(string $text): string
  • md_fences(string $text, array &$ph, bool $escaped): string — Replace fenced and inline code with placeholders. $escaped: whether $text already went through h().
  • md_blocks(array $lines, array &$ph = []): string
  • md_list(array $lines, int &$i): string — Parse a list starting at $i (advances $i to the last list line). Two nesting levels.
  • md_table(array $lines, int &$i): string
  • md_inline(string $s): string — Inline formatting on already-escaped text.
  • md_link(string $url, string $label_html): string
  • md_safe_url(string $url, bool $image = false): string — Allow http(s), mailto and site-relative URLs only.
  • md_mentions(string $text): array — Extract usernames mentioned in a markdown source.
  • md_excerpt(string $text, int $max = 160): string — Plain-text excerpt of a markdown source.
  • posts_rerender(): int — Render every post's Markdown again into body_html (in slices, so a big forum fits the request). Returns the number of posts.

core/migrate.php

  • migrate_import_sqlite(string $file, ?callable $progress = null): array — Returns a report array: ['tables' => [name => rows], 'skipped' => [name => reason], 'seconds' => float].
  • migrate_create_table_like_sqlite(PDO $src, string $table): bool — Create a plugin table in the destination by translating SQLite column types.

core/plugin.php

  • plugin_id_valid(string $id): bool
  • plugin_path(string $id, string $file = ''): string
  • plugin_url(string $id, string $file): string — Public URL for a static file inside plugins/<id>/assets/.
  • plugins(bool $refresh = false): array — Registered plugins keyed by id (rows from fb_plugins with decoded JSON).
  • plugin_enabled(string $id): bool
  • plugin_manifests(?array $set = null): array — Live manifests of loaded plugins (from plugin.php, not the DB snapshot).
  • plugin_manifest(string $id): ?array
  • plugin_peek(string $id): ?array — Read id, name, version, description and author of plugins/<id>/plugin.php as text, without executing it. Used for plugins that are not enabled: their code only runs once an admin enables them.
  • plugin_read_manifest(string $id): ?array — Include plugins/<id>/plugin.php once and return its manifest (null when invalid). Only call this for enabled plugins or on an explicit admin/CLI action.
  • plugins_load(): void — Load every enabled plugin and register what it declares. Called from app_boot().
  • plugin_settings_schema(string $id): array
  • plugin_settings(string $id): array — All settings for a plugin, defaults from the manifest merged with saved values.
  • plugin_setting(string $id, string $key, mixed $default = null): mixed
  • plugin_save_settings(string $id, array $settings): void
  • plugin_settings_from_post(string $id, array $post): array — Validate submitted values against the manifest schema. Unknown keys are dropped.
  • plugin_settings_form(string $id): string — Render admin form fields from the manifest schema.
  • plugin_sync(): array — Scan plugins/ and update fb_plugins. New plugins are registered disabled.
  • plugin_enable(string $id): void — Enable a plugin. The request that uploaded a new zip still holds the previous manifest (plugin_read_manifest() caches per request), so the version recorded here stays the old one on purpose: plugin_sync() on the next request sees the difference, runs the install routine with the new code loaded and records the new version (see plugin_sync_pending).
  • plugin_disable(string $id): void
  • plugin_uninstall(string $id): void — Run the plugin's uninstall callback (drops its tables) and forget its settings. Files stay.
  • plugin_delete_files(string $id, bool $forget = true): void — Remove plugins/<id>/ from disk. $forget=false keeps the registry row (settings, enabled state) for a reinstall.
  • plugin_install_zip(string $file): string — Install (or replace) a plugin from a zip that contains exactly one top-level folder <id>/ with plugin.php. Used by the admin upload form and by the marketplace client. Returns the plugin id; throws RuntimeException.
  • plugin_assets_build(): void — Concatenate CSS/JS declared by enabled plugins into data/cache/plugins.css|js. 'assets' => ['css' => 'my_css_function' | 'assets/style.css' | [..], 'js' => ...]
  • plugin_assets_tag(string $type): string
  • plugin_assets_serve(string $type): never

core/points.php

  • points_rules(): array — The rule table: defaults from the core and the plugins (hook points.rules), numbers and switches from Admin → Points.
  • points_rule(string $id): ?array
  • points_day_start(): int — Midnight today, for the daily caps.
  • points_award(int $user_id, string $rule_id, int $ref_id = 0, string $note = ''): bool — Give a member what a rule says: nothing when the rule is off or worth 0, when the daily cap is reached, or when this object (a post, a day, a task) already paid this member under the same rule. Returns true when points moved.
  • points_revoke(int $user_id, string $rule_id, int $ref_id, string $reason, string $note = ''): bool — Take back what a rule paid for an object (a like undone, a post deleted): only what was really paid, once.
  • points_reasons(): array
  • points_label(string $reason): string
  • points_add(int $user_id, int $delta, string $reason, int $ref_id = 0, string $note = ''): bool — Change a user's balance. Returns false when vetoed or nothing to do.
  • points_of(int $user_id): int
  • points_log(int $user_id, int $page = 1, int $per_page = 30, string $kind = ''): array — ['rows' => [...], 'pagination' => paginate_calc()]; $kind '' = everything, 'in' = earned, 'out' = spent. Rows carry label and url.
  • points_month(int $user_id): array — [earned, spent] this month, as positive numbers.
  • points_log_links(array $rows): array — Adds 'label' and 'url' to ledger rows in two queries at most: reasons about a post (reply, like…) link to the post, "topic" to the topic; plugins answer for their own reasons through points.ref_url.
  • points_top(int $limit = 10): array — Top balances (public columns). Used by leaderboard plugins.
  • points_public(array $user): bool — Whether a user shows the balance on the public profile (preference, default on).
  • points_log_html(array $rows, string $empty = '', bool $by_day = false): string — HTML list of history rows (the /points page, Settings → Points and the admin user drawer): an icon for earned or spent, what happened and where with the time under it, the amount in a column of its own. $by_day puts a Today / Yesterday / date line above each day.
  • points_icon(string $reason, int $delta): string — The icon of a history line, by reason; plugins answer for their own reasons through the points.icon filter. Memory only.

core/render.php

  • view(string $view, array $vars = []): string
  • view_render(string $__file, array $__vars): string — Include one template file with $vars extracted and return its output (buffers are unwound when it throws).
  • page(string $title, string $main, array $opts = []): never
  • sidebar_cards_default(): array — Default right-column cards; plugins add through region.sidebar.right.cards.
  • slot(string $name, array $ctx = [], string $default = ''): string — Lightweight inline slot for positions rendered inside loops (topic rows, posts). Runs the hook "region.<name>" but skips admin HTML blocks and uses a <span>. Hook callbacks here must not query the database (see docs/PLUGIN.md, N+1 rule).
  • site_stats(): array
  • bio_html(string $bio): string — A member's bio as HTML: escaped plain text (no markdown) with @mentions linked to the profiles.
  • card(string $title, string $body, string $class = '', string $extra = ''): string
  • logo_mark(int $size = 28, string $class = ''): string — Default logo mark (rounded square, F + dot). Fill follows --brand; assets/favicon.svg is the same drawing.
  • logo_style(): string — How the header shows the site (Admin → Settings → General → Logo): icon (the icon and the site name), image (the uploaded logo) or name (the name alone). Unset, a site with an uploaded logo keeps showing it; "image" without an uploaded logo falls back to the icon.
  • site_icon_html(int $size = 28): string — The site's icon: the uploaded square icon, or the default mark.
  • site_logo_html(): string — The header's link home in the chosen style. The full logo swaps to its dark version on dark themes, and on phones gives way to the icon when there is one; the name next to the icon hides on phones unless the admin keeps it.
  • image_field(string $name, string $value, array $opts = []): string — A picture setting: the picture (or $opts['empty'], a placeholder), Upload / Replace and Remove. With $opts['action'] (a URL answering field_action()) a picked file is saved at once and Remove acts at once with an Undo; without JavaScript the file goes with the form and a Remove box does the rest ($name . '_remove'). $opts: action, accept (extensions), label (for the toasts), empty (placeholder HTML), wide, url (the picture's own URL when $value is not an upload path), attr (extra attributes for the file input).
  • secret_field(string $name, string $saved, array $opts = []): string — A saved key: "Saved · ends in …ab12" with Change and Remove (at once, with an Undo, through $opts['action']), or the input when none is saved or Change was pressed. A new key goes with the form's Save; empty keeps the saved one.
  • field_action(string $scope, string $current, callable $store, ?callable $upload, string $label): never — The server half of image_field() / secret_field(): POST do = upload (the file in "file") | remove | restore. $store writes the new value ('' removes); $upload returns the stored path of the uploaded file (RuntimeException: its message is shown); $scope keys the Undo. Answers JSON: ok, message, url (pictures).
  • avatar_field_action(array $user): never — field_action() for a member's avatar: $user's picture, saved or removed at once; the letter avatar when empty.
  • avatar_field(array $user, string $action, int $size = 96): string — The avatar as an image_field(): round, the letter avatar as its placeholder.
  • asset_url(string $file): string — Address of a core asset (app.css, app.js) with a version that changes whenever the file's content changes, so browsers and proxies fetch a re-uploaded file: FLATBB_VERSION, a short hash of the file, and the stamp Tools -> Clear cache sets.
  • icon(string $name, string $class = ''): string
  • icon_paths(): array
  • avatar(?array $user, int $size = 32, bool $link = true, bool $marks = true): string — Avatar image or letter fallback. $user needs id, username, avatar (and display_name for the letter and the tooltip). $marks = false leaves out the online dot and the plugins' corner marks (a picture field's placeholder).
  • user_online(array $user): bool — Whether a member counts as online: seen in the last 15 minutes (the window the Statistics card uses) and the setting online_dot is on.
  • time_tag(int $ts, string $fmt = 'rel', string $class = ''): string — <time> for a unix timestamp. The server prints the site time zone (relative text, or an absolute date after a month) with the absolute time as tooltip; app.js re-renders both in the visitor's own time zone. $fmt: rel | date (Sep 7, 2026) | month (Sep 2026) | full (2026-09-07 14:05).
  • post_hold_notice(array $hold): string — "You can post again in 3 minutes" with a clock that runs down and puts the form back when it reaches zero. Under ten minutes it ticks by the second; a longer wait shows the words and the local time it ends (app.js does both).
  • user_name(?array $user): string — The name shown for a member: their display name when display names are on and they set one, else the username. Profile addresses, sign-in and @mentions always use the username. null (a deleted account) gives "deleted".
  • user_link(?array $user, string $class = 'user-link'): string
  • icon_any(string $name): string — A built-in icon by name, or an uploaded image (a path under uploads/, e.g. site/cat_3.png) as an icon-sized <img>; '' when neither.
  • category_icon(array $cat): string — The category's icon when the admin picked one or uploaded an image in Admin → Categories, else ''.
  • category_badge(?array $cat, bool $link = true): string
  • tag_badge(array $tag): string
  • pagination(array $p, callable $url_fn): string — Pagination links. $url_fn(int $page): string
  • list_more_html(string $pagination): string — Under a topic list whose site loads more while scrolling (setting list_paging = scroll): the Load more button that assets/app.js presses by itself near the end of the page. It follows the rel="next" link of $pagination, which stays under it (updated as pages come in), so crawlers, visitors without JS and anyone who wants to jump still have pages.
  • member_action_html(string $id, array $a, string $class): string — One member.actions button: a link, or with 'post' a form that POSTs to its url with the CSRF token and back (the page it is on). Keys: label, url, icon, count, title, post; $class carries the place's look (and is-done when the item says done).
  • tabs(array $items, string $class = 'tabs'): string
  • form_row(string $label, string $field, string $help = ''): string
  • input(string $name, string $value = '', array $attr = []): string
  • textarea(string $name, string $value = '', array $attr = []): string
  • select(string $name, array $options, string $value = '', array $attr = []): string
  • checkbox(string $name, bool $checked, string $label): string
  • action_form(string $url, string $button_html, array $hidden = [], string $class = '', string $confirm = ''): string — A tiny POST form with one button (for actions like like/delete/pin).
  • editor(string $name, string $value = '', string $placeholder = '', array $opts = []): string — $opts: scope (draft key such as "topic-new", "reply-12", "post-34"), ctx (passed to editor.options hook).
  • user_menu_items(array $me): array — The account list (region header.user_menu), once per request: the header's account menu shows all of it, the member card the "you" items as a grid of shortcuts. Items: label, url, icon, count, group ("you" by default, or "site"), weight, and card => false to keep an item out of the card's grid (Profile: the card's own avatar and name already open it).
  • quick_prefs_html(): array — Theme and language as two small round buttons plus the row of languages the second one opens: on phones they leave the header for the account menu (members) or the top of the drawer (visitors). Same controls as the header's: data-toggle="theme" and the POST /language form. Returns [buttons, languages row] ('' for the row when the site has one language).
  • header_right_items(?array $me, int $unread, array $user_menu): array

core/router.php

  • routes(?array $add = null): array
  • router_add(string $pattern, string $handler): void
  • routes_core(): array
  • router_rewrite_check(): never
  • base_path(): string — Directory the app is served from, "" when at the web root, "/forum" when in a subfolder.
  • base_url(): string
  • rewrite_enabled(): bool
  • rewrite_proven(): bool — Whether this very request proves that clean URLs work: it arrived at a path of its own instead of through index.php. Reading the request is the one check no probe can get wrong (a server may answer a request to its own loopback address from another site, and then the probe 404s while every visitor's link is fine).
  • url(string $path = '/', array $params = []): string — Build an in-app URL. url('/t/hello-1', ['page' => 2])
  • absolute_url(string $path = '/', array $params = []): string
  • current_path(): string — The request path relative to the app, e.g. "/t/hello-1".
  • current_url(): string
  • is_active_path(string $path): bool
  • dispatch(): void — Match the current request and call the handler.
  • router_csrf_exempt_add(array $paths): void — Routes that authenticate without a browser session (API tokens) and therefore skip the CSRF check. Core has none; plugins declare them in the manifest: 'csrf_exempt' => ['/api/market/publish'].
  • router_csrf_exempt(string $path, array $add = []): bool
  • routes_final(): array — The routes with plugins' takeovers applied: the filter router.routes (path => handler) lets a plugin serve a core address, such as a portal at /. The admin, sign-in, settings, setup and API addresses always keep the core's handler. Once per request.
  • routes_taken_over(): array — Core addresses a plugin serves instead of the core: path => handler (Admin → Plugins lists them).
  • route_match(string $path): array
  • topic_url(array $topic, int $page = 1, int $post_id = 0): string
  • topic_unread_url(array $topic): string — Where a list sends a member for a topic they have partly read: the first post they have not read (topic_unread()).
  • category_url(array $category): string
  • tag_url(array|string $tag): string
  • admin_url(string $page = '', array $params = []): string

core/schema.php

  • schema_tables(): array
  • schema_indexes(): array
  • schema_install(): void — Create or upgrade all core tables. Safe to run repeatedly.
  • schema_seed(string $admin_name, string $admin_email, string $admin_password): int — Default groups, categories and the welcome topic. Runs once at install.

core/search.php

  • search_backend(): string
  • search_index_install(): void — Create the SQLite FTS5 table when the extension is available. Called from schema_install().
  • search_index_post(int $post_id, int $topic_id, string $title, string $body): void
  • search_delete_post(int $post_id): void
  • search_delete_topic(int $topic_id): void
  • search_update_title(int $topic_id, string $title): void — Retitle every indexed post of a topic.
  • search_query(string $q, int $page = 1, int $per_page = 20): array — Returns ['total' => n, 'rows' => [['post_id'=>..,'topic_id'=>..], ...]]. $filters: ['topic_id' => int, 'user_id' => int] (user filter joins fb_posts).
  • search_rebuild(): int — Rebuild the whole index from fb_posts. Returns number of posts indexed.

core/security.php

  • csp_nonce(): string — Per-request nonce for inline scripts (layout, plugins via script_tag()).
  • csp_policy(): array — CSP directives as name => sources. Plugins add their CDNs through the security.csp filter.
  • security_headers(): void — Send the security headers once per request (called by dispatch() before any output).
  • script_tag(string $inline = '', string $src = '', array $attr = []): string — A <script> tag that passes the CSP: inline code or an external src. Plugins use this instead of writing the tag by hand.
  • csp_report_handle(): never — POST /csp-report (no CSRF: sent by the browser). Appends one line per report to data/csp-report.log, capped at 512 KB.
  • csp_report_file(): string
  • csp_reports(int $n = 20): array — The last $n CSP reports, newest first.
  • cloudflare_ranges(): array — Cloudflare's published ranges (setting trusted_proxies = "cloudflare").
  • trusted_proxy_ranges(): array — Ranges from the trusted_proxies setting: "cloudflare" and/or IPs and CIDRs separated by commas or spaces.
  • ip_in_cidr(string $ip, string $cidr): bool — Whether an IPv4/IPv6 address lies in a CIDR range (a bare address means /32 or /128).
  • ip_in_ranges(string $ip, array $ranges): bool
  • client_ip_resolve(array $server): string — The visitor's address: REMOTE_ADDR, or the forwarded address when the request came through a trusted proxy.
  • admin_log(string $action, string $target = '', string $detail = ''): void — Record an admin action (who, real IP, what, on which object). Fires admin.action for plugins.
  • admin_log_recent(int $n = 20): array — Last $n admin actions with the acting users loaded.
  • sudo_ttl(): int — How long a password confirmation lasts, in seconds (Settings → Security, minutes; 0 = confirmation switched off).
  • sudo_signature(array $user, int $exp): string
  • sudo_ok(): bool — Whether the current admin confirmed the password within the confirmation window (always true when the window is 0 = off).
  • sudo_grant(array $user): void — Remember a successful password confirmation for sudo_ttl() seconds.
  • need_sudo(): void — Require a fresh password confirmation for high-risk admin pages: on GET the admin is sent to the confirm page and comes back afterwards; a POST without confirmation is refused so a riding script cannot change anything.

core/theme.php

  • plugin_is_theme(?array $manifest): bool
  • themes(): array — Installed themes keyed by id: the manifest snapshot taken when the folder was scanned, plus 'enabled'. Never runs theme code.
  • theme_enabled_id(): string — The theme that is switched on for everyone ('' = the core look).
  • theme_active(): string — The theme this request renders with: the one an admin is previewing, else the enabled one.
  • theme_preview_id(): string — The installed theme the signed-in admin is previewing ('' when none).
  • theme_manifest(string $id): ?array — A theme's manifest: the running one for the enabled theme, read as text (never executed) for any other.
  • theme_screenshot(string $id, array $m = []): string — A theme's preview image inside its folder: manifest 'screenshot', else screenshot.png|jpg|webp; '' when there is none.
  • theme_token_name(string $name): string — "brand" or "--brand" → "--brand"; '' when the name is not a plain CSS variable name.
  • theme_token_value(string $value): string — A token value that is safe to print inside <style>; '' when it could close the rule, load something or break out of the tag.
  • theme_tokens(string $id, ?array $m = null): array — The variables a theme sets, grouped by colour mode: ['all' => [--name => value], 'light' => [...], 'dark' => [...]]. Sources: manifest 'tokens', then settings that carry 'token' (saved value, else the default; 'unit' is appended to numbers, 'values' maps a saved value to CSS, 'mode' picks light or dark). A --brand without its shades gets them derived.
  • theme_tokens_css(array $tokens): string — CSS for grouped tokens. Light and dark follow the page's data-theme, and "auto" follows the visitor's system.
  • theme_head(): string — <style> with the active theme's tokens, printed right after app.css (layout.php).
  • theme_css_source(string $id, bool $run_code): string — A theme's CSS: files from 'assets' => ['css' => [...]], and function sources only when $run_code (the enabled theme).
  • theme_assets_build(): void — Write data/cache/theme.css for the enabled theme (called with the plugin bundle).
  • theme_assets_tag(): string — The theme stylesheet, after the plugin bundle. A previewed theme that is not enabled gets its CSS files inline instead.
  • theme_view_file(string $view): string — The theme's file for a template, or '' to use app/views. Admin pages always use the core templates, so a broken theme cannot lock anyone out.
  • theme_view_failed(string $view, string $file, Throwable $e): void — A theme template threw: log it, remember it for Admin → Themes (once per message), and let view() render the core template.
  • theme_view_errors(string $id): array — Errors a theme's templates raised: [view => message]. An error disappears once its template file changes (fixed or updated).
  • theme_core_view_hash(string $view): string — First 12 hex digits of sha1 over a core template (line endings normalised): the stamp a theme's copy records.
  • theme_view_stamp(string $file): array — ['view' => name, 'hash' => 12 hex] from the "flatbb-view: name@hash" stamp at the top of a theme template; [] when absent.
  • theme_views(string $id): array — The templates a theme replaces and how each compares with the core template it was copied from: view => ok | outdated (the core template changed since) | unstamped (no stamp line) | unknown (no such core template).
  • theme_preview_bar(): string — The bar on top of every page while an admin previews a theme: whose look it is, Activate, Exit. page() inserts it after <body>.
  • theme_preview_inject(string $html): string

core/theme_dev.php

  • theme_known_tokens(): array — Every CSS variable assets/app.css defines or reads (component tokens exist only as var() fallbacks): the names a theme can set.
  • theme_check(string $id, array $m): array — The rules a theme passes on top of plugin_check(): [errors, warnings]. $m is the loaded manifest.
  • theme_override(string $id, string $view, string $mode = ''): string — Copy app/views/<view>.php into plugins/<id>/views/ with a stamp line naming the core template and its hash. $mode: '' (refuse to overwrite), 'force' (copy again, the old copy is kept as <view>.php.bak), 'stamp' (keep the copy, record today's core hash after you brought the core changes over). Returns the file path.
  • theme_scaffold(string $id, string $name = ''): array — Create plugins/<id>/ with a working starter theme (tokens, a colour setting, an empty stylesheet, a README). Returns the files written.

core/upgrade.php

  • upgrade_endpoint(): string
  • upgrade_check(bool $force = false): array — ['version','url','download','sha256'] or ['error' => ...]. Cached for 6 hours unless $force.
  • upgrade_available(): ?array
  • upgrade_http_get(string $url, int $max_bytes, ?string &$error = null): ?string
  • upgrade_paths(): array — Files and directories replaced by an upgrade (relative to ROOT).
  • upgrade_apply(?string $zip_file = null, ?callable $log = null): string — Download and apply the latest release (or a given zip file). Returns the new version string. Throws RuntimeException with a user-readable message; the site is only modified after the package was verified.
  • upgrade_keep_language_packs(string $old_dir, string $new_dir): array — lang/ is replaced as a whole: put back the packs the release does not ship (a site's own translation, lang/bg.php). A pack the release does ship is the release's. Returns the file names put back.
  • upgrade_restore(array $done, string $backup, string $root = ROOT): void — Put back what an interrupted upgrade replaced: every path in $done from the backup (a path that did not exist before goes).
  • upgrade_copy(string $from, string $to): void
  • upgrade_rmdir(string $dir): void
  • upgrade_unlink(string $file): void — Remove a file. On Windows the running script stays open (index.php under php-cgi, flatbb on the command line): deleting it only marks it, its name stays taken until the process ends and the new file cannot be written. Moving it aside first frees the name at once; the moved copy goes when the process ends (or with the next cache clear).
  • upgrade_build_release(): array — Maintainers: build dist/flatbb-<version>.zip from this checkout and print its sha256.

core/upload.php

  • upload_url(string $path): string — The address of an uploaded file. The filter upload.url (ctx: path) lets a plugin serve files from a CDN or an object store it copies them to (upload.after_save). It runs for every avatar and attachment on a page: no database access.
  • upload_mime(string $file): string — Sniffed mime type. fileinfo is optional: without it, image files are identified by getimagesize() and everything else stays ''.
  • upload_files_list(string $key): array — $_FILES[<key>] as a list of single-file arrays (a multi-file field is nested by property); files with upload errors are skipped.
  • upload_allowed_types(): array
  • upload_image_exts(): array
  • upload_handle(): never — POST /upload — returns JSON {ok,url,markdown,id,name,is_image}.
  • upload_store(array $user, string $tmp, string $original): array — Validate, move and register an uploaded file. Throws RuntimeException with a user message.
  • upload_shrink_image(string $file, string $ext, int $max_side): void — Downscale large images in place (keeps GIFs untouched). Silently skips when GD is missing.
  • avatar_store(int $uid, string $tmp): string — Square avatar: uploads/avatars/<uid>.jpg. Returns the relative path stored on the user.
  • attachments_link_to_post(int $post_id, int $user_id, string $body): void — Link freshly uploaded attachments to a post (by their ids embedded in the markdown urls).
  • upload_site_image(string $key, array $file, array $exts, int $max_bytes = 2097152): string — Store a site asset (logo, favicon) uploaded from the admin: uploads/site/<key>.<ext>. Returns the relative path for setting(); throws RuntimeException with a user message.
  • svg_sanitize(string $svg): ?string — An uploaded SVG made safe to serve from this site: parsed as XML with no DOCTYPE, no entities and no network, then rebuilt from an allowlist: drawing elements (shapes, paths, groups, gradients, masks, text) with their presentation attributes. Scripts, styles, event attributes, foreign content and links that leave the file (href or url() to anything but #id) are dropped. Returns the cleaned SVG, or null when the file is not an SVG drawing.
  • upload_protect_dirs(): void — Drop protection files into uploads/ and data/ when they are missing: PHP-FPM/CGI reads .user.ini (engine off), Apache reads .htaccess. nginx needs the rules from nginx.conf.example; the Guard plugin's check-up tells whether they work.

core/verify.php

  • register_verify_on(): bool
  • email_code_hash(string $email, string $code): string
  • email_code_issue(string $email, string $ip, ?string &$error = null): string — Create a code for an address (replacing an older one) and return it in clear, or '' with $error set when the address is throttled. mail_send() is left to email_code_send() so tests can check codes without a mail server.
  • email_code_send(string $email, string $ip): string — Issue and mail a code. Returns '' on success or the message to show.
  • email_code_check(string $email, string $code): bool — Check a code for an address; a correct code is consumed. Five wrong attempts void the code.
  • email_mask(string $email): string — "n•••@qq.com": enough to recognise one's own address in a mail or a notice, not enough to read it off.
  • email_restore_link(int $uid, string $old, string $new): string — The link mailed to the old address after an email change: for a week it puts that address back and signs the account out everywhere, so a change the owner did not make can be undone from the inbox that still belongs to them. It is signed with the site secret and bound to the user, both addresses and the expiry; once the address changes again it stops working.
  • email_restore_sig(int $uid, string $old, string $new, int $exp): string
  • email_restore_user(int $uid, string $old, int $exp, string $sig): ?array — The user a restore link is good for right now, or null (bad signature, expired, or the address changed since).
  • mail_transport_label(): string — Which plugin delivers mail (mail.send hook), or PHP's mail() when none does. Shown next to the setting.
  • email_code_cleanup(): int — Cron: codes older than a day.

app/account.php

  • account_login(): never
  • account_register(): never
  • account_forgot(): never — GET|POST /forgot — email a password reset link (never reveals whether the address exists).
  • account_reset(string $token): never — GET|POST /reset/{token}
  • account_lang(): never — POST /language (code, back): the header language switcher. Sets the cookie and the member's preference, then returns to the page.
  • account_logout(): never
  • login_throttle_file(): string — Simple per-IP throttle stored in a cache file (no DB writes on failed logins).
  • login_throttle_ok(): bool
  • login_throttle_hit(): void

app/admin.php

  • admin_index(string $page = 'dashboard'): never — GET|POST /admin[/{page}]
  • admin_ext(string $plugin, string $page): never — GET|POST /admin/ext/{plugin}/{page} — plugin admin pages
  • admin_security_checks(bool $force = false): array — Deployment self-check shown on the dashboard: private directories must not be reachable over HTTP. Results are cached for an hour (setting security_check); POST action=recheck refreshes.
  • admin_settings_fields(): array
  • admin_logo_block(): string — Settings → General → Logo: the three styles as cards, the icon and the logo uploads (a dark version too), the phone option, and a preview of the header (desktop, dark, phone) that follows the choices before they are saved (assets/app.js).

app/admin_ai.php

  • admin_ai_providers(): array
  • admin_ai_settings(array $sections): never — GET and POST of the AI tab. $sections: the settings tabs, for the tab bar.
  • admin_ai_status(?array $conn, array $s): string — The flag in a connection card's head: not set up, resting after an error, last answer, last error.
  • admin_ai_swap(int $a, int $b): void — Move a connection up: swap every field of $a and $b, and what the site remembers about them.
  • admin_ai_test(array $numbers): never — Ask each connection one tiny question and flash one line for all of them; the cards show the details.

app/admin_content.php

  • admin_category_icon_unlink(int $id): void — Remove a category's uploaded icon file(s) (uploads/site/cat_<id>.*).

app/admin_import.php

  • import_job_view(array $job): array — The job without the importer's state and secret, for the page script.
  • import_phase_text(array $p, bool $current, bool $past): string — One phase's numbers: "31,200 / 74,590", "waiting", "done".
  • import_job_html(array $job, string $back): string — Progress bars, status and the job's buttons; a running job carries data-import, which the page script keeps going.
  • import_steps_html(array $labels, int $current): string — Step pills for an importer page: import_steps_html(['Connect', 'Check', 'Import', 'Done'], 2) (English labels, 1-based current step).
  • import_stats_html(array $stats): string — Numbers found in the source forum: import_stats_html(['Members' => 3412, 'Topics' => 9806]).
  • import_duration(float $s): string
  • import_report_download(array $job): never — The report as a text file: what went where, and every note.

app/admin_menus.php

  • admin_menu_items_of(string $region): array — One menu's items with where each came from: id => item + ['_src' => 'core' | 'custom' | plugin id, '_edited' => bool, '_default' => the item before the admin's edits].

app/admin_points.php

app/admin_system.php

  • admin_layout_items(string $region): array — Every item of a list region as the site shows it: the core's own entries plus what plugins add, in the effective order (hidden ones included). Each plugin callback runs on its own with ctx ['layout' => true]; a plugin that adds nothing here (its card only shows on some pages, or to members) is listed under its own id and name, so the admin can still place it. Plain HTML items take their label from the card heading.
  • admin_tools_security_html(): string — Tools page: the last admin actions and the last CSP reports (the security plugin shows the full history).

app/admin_theme.php

  • admin_themes_post(string $back): never — Every POST of the page (the preview bar's buttons post here too).
  • admin_theme_zip_manifest(string $file): ?array — The manifest of plugin.php inside an uploaded zip, read as text (the file is not executed); null when there is none.
  • admin_theme_card_default(bool $on): string — The built-in look, always first.
  • admin_theme_card(string $id, array $t, bool $on): string
  • admin_theme_swatch(array $tokens): string — A small drawing of a page in a theme's colours, for themes without a screenshot. [] = the default look.

app/admin_ui.php

  • admin_menu_items(string $active): array
  • admin_page(string $title, string $body, string $active = '', array $opts = []): never
  • admin_drawer_links(array $d): string
  • admin_drawer_html(array $d): string
  • admin_drawer_link(string $url, string $label, string $class = 'btn btn-sm', string $icon = ''): string
  • admin_row_menu(array $items): string — "…" menu: pass a list of link/form HTML; forms should be action_form() with a plain button.
  • admin_switch(string $url, array $hidden, bool $on, string $title = ''): string — Toggle switch posted via AJAX: admin_switch(admin_url('plugins'), ['action' => 'disable', 'id' => $id], true)
  • admin_table(array $head, array $rows, string $empty = '', array $sort = []): string — $sort = ['region' => …, 'url' => …] makes the rows draggable: the row keys are the item ids, a drop posts action item_order with the ids in their new order to url (app.js), like the chips of Admin → Widgets.
  • admin_form_actions(string $primary, string $cancel_url = ''): string — Sticky save bar used at the end of drawer forms.

app/api.php

  • api_dispatch(string $action): never — Small JSON API used by app.js, plus sitemap and RSS. POST /api/preview body -> {html} GET /api/users?q= -> [{username, avatar}] for @mention autocomplete GET /api/unread -> {count} any /api/<action> -> hook api.<action> for plugins (return array to respond)
  • feed_topics(array $rows): array — Topics that may appear in a public feed: plugins drop what a visitor without an account may not read (hook feed.topics).
  • seo_sitemap(): never
  • seo_manifest(): never — Web app manifest: "Add to home screen" uses the site name and icon instead of the page title.
  • seo_rss(): never

app/category.php

  • categories(): array — All categories keyed by id, ordered by sort. Cached per request.
  • category_by_id(int $id): ?array
  • category_by_slug(string $slug): ?array
  • category_can_view(array $c): bool
  • category_can_post(array $c): bool
  • category_visible_ids(): ?array — Ids of categories the current user may see; null means "no restriction".
  • category_tree(): array — Visible categories grouped as parent => children for menus.
  • category_refresh_stats(int $id): void
  • category_index(): never — GET /categories
  • category_view(string $slug): never — GET /c/{slug} — the category's latest topics (children included).
  • category_top(string $slug, string $period = 'week'): never — GET /c/{slug}/top[/{period}]
  • category_unread(string $slug): never — GET /c/{slug}/unread
  • category_list(string $slug, string $mode, string $period = 'week'): never — One category page: Latest / Top / Unread limited to the category and its children.

app/home.php

  • home_index(): never — GET /
  • home_latest(): never
  • home_top(string $period = 'week'): never
  • home_unread(): never
  • top_periods(): array — Top periods: key => seconds back (0 = all time).
  • top_scope(string $period): array — Where clause and params for a Top period (404 on an unknown one); '' means no time limit.
  • top_order(): string
  • top_sub_tabs(string $period, string $base): string — Period tabs under Top; $base is '/top' or '/c/<slug>/top'.
  • unread_scope(array $me): array — Unread for a member: [join, where, params] — topics with posts they have not seen, last 30 days.
  • topic_list_page(): array
  • topic_list_fetch(string $where, array $params, string $order, array $p, bool $pinned_first = false, string $join = ''): array — Load a page of topics with users, categories and tags attached (batched, no N+1). $where uses alias t for fb_topics. Returns ['topics'=>[], 'pagination'=>[]].
  • topic_list_new_config(array $topics, array $category_ids = []): array — What the "See N new or updated topics" pill of a Latest list asks about: the newest activity the page shows, and its categories.
  • topic_list_new_count(int $since, array $category_ids = []): int — Topics that became new or got a reply after $since, with the visibility of the lists themselves (hidden categories, the topic_list.query filter), so a count never reveals a topic the reader could not open. A member's own posts do not count.
  • topic_list_attach(array $rows): array — Attach user, last_user, category, tags and (for members) unread flag to topic rows.
  • topic_list_page_render(string $title, array $list, string $active, callable $url_fn, string $sub_tabs = '', array $opts = []): never — Render a standard list page with tabs.
  • category_bar(string $active): string — Category bar at the top of list pages (page option 'top'; region main.categories, list): All + top-level categories, the current one highlighted (a child page highlights its parent). Setting category_bar: mobile (default; the left column is hidden there) | always | off. Icons appear only for categories that have one.
  • new_topic_url(): string — The New Topic address: inside a category list (set by list_tabs()) the composer opens on that category.
  • list_tabs(string $active, array $extra = [], ?array $category = null): string — Tabs above topic lists (region main.tabs). Inside a category the tabs and New Topic stay in that category.

app/moderation.php

  • user_moderate_refusal(array $target, array $by): string — Why $by may not act on $target ('' = may).
  • user_ban(int $uid): void — Suspend a member and sign them out on every device.
  • user_unban(int $uid): void
  • user_remove_content(int $uid, int $by): array — Remove everything a member wrote (soft: moderators can restore a post) and reject what waits in the review queue. Counts of the topics, categories and tags touched are brought up to date. Returns ['topics' => n, 'replies' => n].
  • user_delete(int $uid): array — Delete a member and everything they wrote, for good: their topics (with the replies in them), their replies elsewhere, likes, bookmarks, read marks, notifications to and from them, uploaded files, points and review items. Counts of what stays are brought up to date. Plugins clear their own rows on user.after_delete (ctx: user_id, topic_ids, post_ids). Returns ['topics' => n, 'replies' => n, 'files' => n].
  • upload_file_delete(string $path): bool — Remove a file under uploads/ (a stored path, with or without its ?v= stamp). Never leaves uploads/.
  • user_moderate(array $uids, string $action, array $by): array — Apply an action (ban | remove | delete | unban) to members, as $by. Members $by may not act on are skipped. Returns ['done' => [uid, …], 'skipped' => [uid => reason], 'topics' => n, 'replies' => n].
  • user_moderate_message(array $r, string $action): string — The message after an action.
  • user_moderate_page(string $name): never — GET|POST /u/{name}/moderate — what the member did, and the actions. Moderators and administrators.

app/notification.php

  • notify_wanted(int $to, string $kind): bool — Whether the member wants this kind of notification (Settings → Preferences): the preference notify_<kind> set to 0 turns a kind off, so a plugin's own kind gets a switch by saving that key (user.prefs_save). Kinds nobody switched off are sent.
  • notify_pref_on(string $prefs, string $kind): bool
  • notify(int $to, int $from, string $kind, string $content = '', int $topic_id = 0, int $post_id = 0): bool — Create a notification. Returns false when suppressed (self, duplicate, preference, hook veto).
  • notify_many(array $to, int $from, string $kind, string $content = '', int $topic_id = 0, int $post_id = 0): int — Notify many members of the same thing (a new topic for everyone who follows its author) in a few queries: recipients are read 100 at a time (active accounts that did not switch the kind off), written with one INSERT per 100 and their unread counts raised with one UPDATE each. The filter notification.before_create runs for every recipient (no DB there); notification.after_create_many fires once. Returns how many were notified.
  • notify_reply(array $topic, int $post_id, int $from, int $reply_to_id): void
  • notify_mentions(int $topic_id, int $post_id, string $body, int $from): void
  • notify_like(array $post, int $from): void
  • notifications_unread(): int
  • notification_index(): never — GET /notifications
  • notification_read(): never — POST /notifications/read — mark all read (AJAX)

app/points.php

  • points_wallet(): never — GET /points[?kind=in|out&page=n]

app/review.php

  • review_hold_reason(string $kind, int $user_id, int $category_id, string $text): string — Why a new topic ('topic') or reply ('reply') by $user_id waits for review, '' when it goes out at once. The reason is an English source text, shown through t(). Plugins add their own with the filter review.hold (value: the reason so far; ctx: kind, user, category, text); administrators and moderators are never held.
  • review_trusted(array $user): bool — A member a moderator approved "and trusted": their posts no longer wait for the new-member rule.
  • review_hold(string $kind, int $topic_id, int $post_id, int $user_id, string $reason): void — Put a held topic or reply in the queue and tell the moderators (one unread notice each, however many wait).
  • review_staff_ids(): array — Active administrators and moderators.
  • review_count(): int — How many items wait (one query a request; the account menu shows it to moderators). Items whose post was deleted meanwhile do not count.
  • review_approve(array $r, int $by): bool — Approve a waiting item: the content goes public and sets off what a new post sets off; its author is told.
  • review_reject(array $r, int $by, string $note): bool — Reject a waiting item: the content is deleted (moderators can still see it) and its author gets the note.
  • review_trust(array $r, int $by): int — Approve every waiting item of this member and trust them: the new-member rule no longer holds their posts.
  • review_ban(array $r, int $by): bool — A spammer: suspend the account, delete everything they wrote (their waiting items are rejected), and bring the counts of the topics and categories they wrote in up to date.
  • review_page(): never — GET /review[?tab=waiting|approved|rejected]: the queue, for moderators.
  • review_act(): never — POST /review/act: id, do = approve | trust | reject | ban, note (reject). Answers JSON to the page's script, else goes back.

app/search.php

  • search_page(): never — GET /search?q=
  • search_snippet(string $body, string $q): string — Excerpt around the first matching term, with <mark> highlights (safe HTML).

app/setup.php

  • setup_index(): never — Web installer (/setup). One form: database, site name, administrator. Writes data/config.php only after the schema and seed data were created successfully.
  • setup_checks(): array
  • setup_rewrite_works(): bool — Probe /__rewrite_check on this host; true when clean URLs work.

app/tag.php

  • tag_normalize(string $name): string
  • tags_parse(string $csv): array — Parse "a, b, c" into normalized unique names (max 5).
  • tags_for_topics(array $topic_ids): array — Tags for many topics: [topic_id => [tag rows]]. One query.
  • topic_set_tags(int $topic_id, array $names): void — Replace a topic's tags. Creates missing tags and keeps counts accurate.
  • tag_index(): never — GET /tags
  • tag_view(string $name): never — GET /tag/{name}

app/topic.php

  • topic_by_id(int $id): ?array
  • post_by_id(int $id): ?array
  • topic_title_valid(string $title): bool
  • post_body_valid(string $body): bool
  • topic_create(int $category_id, int $user_id, string $title, string $body, array $tags = [], bool $pinned = false): int — Create a topic with its first post. Returns topic id. When review_hold_reason() holds it (app/review.php) both rows are stored with is_deleted = 2 and wait in the review queue: counts, search, @mentions, topic.after_save, points and link previews happen when a moderator approves it (topic_go_public()), so nobody hears of a post nobody may see yet.
  • topic_go_public(int $tid): void — What a topic sets off when it becomes public: at once for most, on approval for one that waited in the review queue.
  • post_create(array $topic, int $user_id, string $body, int $reply_to = 0): int — Add a reply. Returns post id. A held reply (review queue) waits with is_deleted = 2; post_go_public() runs on approval.
  • post_go_public(int $pid): void — What a reply sets off when it becomes public: the topic's last reply and counts, search, notifications, post.after_save, points, link previews.
  • topic_stats_refresh(int $tid): void
  • topic_mark_read(int $tid, int $last_post_id): void
  • can_manage_topic(array $topic): bool
  • content_visible(array $row): bool — Whether the current user may see this topic or post row by its is_deleted state: shown (0) to everyone, deleted (1) to moderators, waiting for review (2) to moderators and its author.
  • post_topic_visible(array $post): ?array — The topic of a post when the current user may see it (content_visible(), category viewable), else null.
  • can_edit_post(array $post): bool
  • topic_view(string $id): never — GET /t/{slug}-{id}
  • topic_unread(string $id): never — GET /t/{id}/unread: the page of the first post the member has not read, scrolled to the "New replies" line above it (the way Discourse and Flarum do). A topic opened only on its first page stayed unread for good when the new replies were on a later page. Nothing read yet, all read, a visitor or no access: the topic itself.
  • topic_new_from(array $posts, int $prev_read): int — The first post on this page the member had not read before this visit (the "New replies" line goes above it); 0 when none or never opened.
  • topic_first_unread(array $topic, int $uid): ?array — ['post_id' => …, 'page' => …] of the first post $uid has not read in $topic, or null (never opened, nothing unread, a visitor).
  • topic_reply_denied(array $topic): string — Why the visitor may not reply to this topic, '' when they may (hook topic.can_reply); moderators and the author always pass.
  • topic_access_denied(array $topic): string — Why the visitor may not read this topic, '' when they may. Plugins answer with the reason to show (hook topic.access); moderators and the author always pass. The same reason keeps a topic out of search and the feeds.
  • topic_no_access(array $topic, ?array $cat, string $why): never — The page a refused visitor gets: the title stays readable, the body is the reason.
  • topic_count_view(int $id): void — One view per visitor and topic: a cookie remembers the topics this browser opened in the last day, so a reload or a second page of the same topic adds nothing. Members and guests count the same way.
  • posts_attach(array $posts, array $topic): array — Attach user rows, liked flag and reply-to previews to posts (batched).
  • topic_related(array $topic): array
  • topic_new(): never — GET|POST /new-topic
  • topic_category_fallback(string $title, string $body, array $me): ?array — A new topic without a category: a plugin may pick one (filter topic.category_missing, ctx title, body, user; return a category id, e.g. what an AI chose), then the default category when the site does not require one. null: ask the writer.
  • topic_category_auto(): bool — Whether a plugin will pick the category of a topic sent without one (filter topic.category_auto: true when it can right now).
  • topic_category_optional(): bool — Whether the composer may leave the category empty: not required, or a plugin that picks one is ready.
  • topic_reply(string $id): never — POST /t/{id}/reply
  • topic_edit(string $id): never — GET|POST /t/{id}/edit — title, category, tags and first post.
  • post_update(array $post, string $body, int $editor_id): void
  • topic_action(string $id): never — POST /t/{id}/action action=pin|unpin|lock|unlock|delete|restore|move (pin on a pinned topic moves it to the top of the pinned ones)
  • topic_bookmark(string $id): never — POST /t/{id}/bookmark — toggle
  • post_permalink(string $id): never — GET /post/{id} — jump to the page containing the post
  • post_edit(string $id): never — GET|POST /post/{id}/edit
  • post_like(string $id): never — POST /post/{id}/like — toggle
  • post_delete(string $id): never — POST /post/{id}/delete — soft delete (mods can restore with action=restore)
  • post_raw(string $id): never — GET /post/{id}/raw — markdown source for quoting

app/user.php

  • user_rename_allowed(): bool — Whether members may change their own username (Admin -> Settings -> Registration).
  • user_rename_next(array $user): int — Unix time from which this member may rename again (0 when never renamed).
  • display_name_next(array $user): int — When a member may change their display name again (0 = now); administrators are not limited.
  • user_profile(string $name, string $tab = 'topics'): never — GET /u/{name}[/{tab}] tabs: topics, replies, bookmarks (own only)
  • user_settings(string $tab = 'profile'): never — GET|POST /settings[/{tab}] tabs: profile, avatar, password, preferences
  • user_pref(string $key, mixed $default = null): mixed
  • settings_email_post(array $me, string $back): never — POST /settings/email. action=verify: the code mailed to the current address marks it verified. Otherwise a change, the way the big services do it: the current password, then the new address and the code sent to it (when the site verifies addresses); nothing changes until both check out. The old address is told, with a link that undoes the change for a week.
  • user_email_restore(): never — GET|POST /email/restore — the link from the "your email was changed" mail: confirm, then the old address is back and every session ends.