forums.tonybtw.com

https://git.tonybtw.com/forums.tonybtw.com.git git://git.tonybtw.com/forums.tonybtw.com.git

Add per-board posting permissions (post_min_role)

Commit
cfb78ae54cb8a38e4a55819fb396e50c056a0da6
Parent
7d3da8c
Author
tonybanters <tonybanters@gmail.com>
Date
2026-06-05 23:38:11
Boards gain a post_min_role column (normal < mod < admin). Starting a
thread and replying now require role >= the board's minimum, enforced in
Thread_Controller (new_form/create/reply) and reflected in the UI (the
New thread button / reply form hide for users who can't post, with a
"restricted to Xs" note).

- Role: rank() + meets() for role comparison
- helpers: board_post_min_role() + can_post_in()
- db: schema column, ensure_column + one-time backfill that locks the
  announcements & rules boards to admin on existing DBs; seed defaults
  them to admin on fresh DBs
- admin panel: a Posting (anyone/mods+/admins) selector per board

Diff

diff --git a/README.md b/README.md
index 4fa0448..a0a13b0 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
 # forums-btw
 
-A minimal forum. PHP, SQLite, server-rendered HTML, hand-written CSS. No Composer, no build step. JavaScript is optional and vanilla (no npm) — the whole thing works with JS off.
+A minimal forum. PHP, SQLite, server-rendered HTML, hand-written CSS. No Composer, no build step. JavaScript is optional and vanilla (no npm) - the whole thing works with JS off.
 
-Tokyo Night palette, Iosevka mono — matches my Alacritty/nvim.
+Tokyo Night palette, Iosevka mono - matches my Alacritty/nvim.
 
 ## Run
 
@@ -18,7 +18,7 @@ Structure follows the classic phpBB / Gentoo-forums index (categories → boards
 
 ## Features
 
-- Register / login / logout — sessions, `password_hash`, CSRF tokens
+- Register / login / logout - sessions, `password_hash`, CSRF tokens
 - Boards grouped into categories, all created via `/admin` after first login
 - Threads with an OP post and replies, scoped to a board
 - Roles: `admin`, `mod`, `normal` (first registered user becomes `admin`)
@@ -27,21 +27,21 @@ Structure follows the classic phpBB / Gentoo-forums index (categories → boards
   - admin panel at `/admin` (admin-only): user list with role/join/counts, promote/demote (CSRF-protected, can't change your own role)
   - **bootstrap:** the first account registered on a fresh DB becomes `admin`; everyone after is `normal`. On a public deploy this is first-to-register-wins, so register immediately after going live.
 - Per-user profile pages with role badge
-- Post formatting: **Markdown** via vendored Parsedown (safe mode — raw HTML escaped, no XSS); legacy inline tags (`<b> <i> <u> <s> <code>`) still render
+- Post formatting: **Markdown** via vendored Parsedown (safe mode - raw HTML escaped, no XSS); legacy inline tags (`<b> <i> <u> <s> <code>`) still render
 - Fenced code blocks (```` ```c ````) get syntax highlighting via the bundled `Hl` highlighter (C implemented; other languages fall back to plain text)
 - **Search** over threads + posts via SQLite FTS5 (`/search`, works with no JS), kept in sync by insert/delete triggers
-- **telescope.js** — a Telescope-style fuzzy-finder modal (open with `/` or `Ctrl+K`): live results, list + preview, `↑↓`/`Ctrl-j/k` to move, `⏎` to open. Progressive enhancement over the `/search` page.
-- **Image uploads** (login required) — paste, drag-drop, or pick a file into a post box; inserts `![](…)` Markdown. Local disk under `public/uploads`, up to 25 MB.
+- **telescope.js** - a Telescope-style fuzzy-finder modal (open with `/` or `Ctrl+K`): live results, list + preview, `↑↓`/`Ctrl-j/k` to move, `⏎` to open. Progressive enhancement over the `/search` page.
+- **Image uploads** (login required) - paste, drag-drop, or pick a file into a post box; inserts `![](…)` Markdown. Local disk under `public/uploads`, up to 25 MB.
   - Security: validated by content (`getimagesize`), not extension; only PNG/JPEG/GIF/WebP (no SVG); random filenames; never writes/executes a `.php`. `public/uploads/.htaccess` disables script execution + sets `nosniff` (Apache); on nginx, serve `/uploads` statically and don't route it through PHP.
   - No-JS fallback: the `/upload` page returns a Markdown snippet to paste.
   - Dev server raises PHP upload limits via flags in the `justfile`; production must set `upload_max_filesize`/`post_max_size` accordingly.
 - Server-side **Preview** button on the new-thread and reply forms (no JavaScript)
 - **Tokyo Night** theme (matches my Alacritty/nvim) and a lualine-style modeline footer with contextual segments
-- Optional **vim mode** in the post boxes — a single vendored `public/js/vim.js` (no npm, no build, off by default, preference saved in localStorage). Supports normal/insert/visual, `hjkl w b e 0 ^ $ gg G`, `i a A I o O`, `x dd D dw cw C r`, `yy p P`, `u`, visual `d y c x`, and `:w`/`:wq`/`:x` to submit. The textarea works normally with JS disabled.
+- Optional **vim mode** in the post boxes - a single vendored `public/js/vim.js` (no npm, no build, off by default, preference saved in localStorage). Supports normal/insert/visual, `hjkl w b e 0 ^ $ gg G`, `i a A I o O`, `x dd D dw cw C r`, `yy p P`, `u`, visual `d y c x`, and `:w`/`:wq`/`:x` to submit. The textarea works normally with JS disabled.
 
 ## Layout
 
-Procedural, data-oriented MVC — free functions over plain data, enums for fixed
+Procedural, data-oriented MVC - free functions over plain data, enums for fixed
 types, globals for shared state (`$dbh`, `$current_user`, `$route`, `$params`).
 No classes-as-services, no DI.
 
diff --git a/controllers/Admin_Controller.php b/controllers/Admin_Controller.php
index 295d567..5d80c7b 100644
--- a/controllers/Admin_Controller.php
+++ b/controllers/Admin_Controller.php
@@ -42,7 +42,8 @@ final class Admin_Controller {
     /**
      * this function validates and normalises the board fields shared by create
      * and update. it renders a 400 (and does not return) on bad input,
-     * otherwise hands back [slug, name, description, category, position]
+     * otherwise hands back [slug, name, description, category, position,
+     * post_min_role]
      *
      * @return array
      */
@@ -52,6 +53,8 @@ final class Admin_Controller {
         $description = trim($_POST['description'] ?? '');
         $category    = trim($_POST['category'] ?? '');
         $position    = (int) ($_POST['position'] ?? 0);
+        // only allow the known roles; anything else falls back to open posting
+        $post_min_role = (Role::tryFrom($_POST['post_min_role'] ?? 'normal') ?? Role::Normal)->value;
 
         if (!preg_match('/^[a-z0-9-]+$/', $slug)) {
             Render::render_error(Error_Type::BAD_REQUEST);
@@ -62,7 +65,7 @@ final class Admin_Controller {
         if ($category === '') {
             $category = 'general';
         }
-        return [$slug, $name, $description, $category, $position];
+        return [$slug, $name, $description, $category, $position, $post_min_role];
     }
 
     /**
@@ -74,11 +77,11 @@ final class Admin_Controller {
     public static function create_board() : never {
         Session::require_admin();
         Session::require_csrf();
-        [$slug, $name, $description, $category, $position] = self::board_input();
+        [$slug, $name, $description, $category, $position, $post_min_role] = self::board_input();
         if (Boards_Model::get_by_slug($slug)) {
             Render::render_error(Error_Type::CONFLICT);
         }
-        Boards_Model::create($slug, $name, $description, $category, $position);
+        Boards_Model::create($slug, $name, $description, $category, $position, $post_min_role);
         redirect('/admin');
     }
 
@@ -96,12 +99,12 @@ final class Admin_Controller {
         if (!Boards_Model::get($id)) {
             Render::render_error(Error_Type::NOT_FOUND);
         }
-        [$slug, $name, $description, $category, $position] = self::board_input();
+        [$slug, $name, $description, $category, $position, $post_min_role] = self::board_input();
         $clash = Boards_Model::get_by_slug($slug);
         if ($clash && (int) $clash['id'] !== $id) {
             Render::render_error(Error_Type::CONFLICT);
         }
-        Boards_Model::update($id, $slug, $name, $description, $category, $position);
+        Boards_Model::update($id, $slug, $name, $description, $category, $position, $post_min_role);
         redirect('/admin');
     }
 
diff --git a/controllers/Thread_Controller.php b/controllers/Thread_Controller.php
index c53e2b1..15bc288 100644
--- a/controllers/Thread_Controller.php
+++ b/controllers/Thread_Controller.php
@@ -28,11 +28,14 @@ final class Thread_Controller {
      * @return never
      */
     public static function new_form(array $params) : never {
-        Session::require_login();
+        $current_user = Session::require_login();
         $board = Boards_Model::get_by_slug($params['slug']);
         if (!$board) {
             Render::render_error(Error_Type::NOT_FOUND);
         }
+        if (!can_post_in($current_user, $board)) {
+            Render::render_error(Error_Type::FORBIDDEN);
+        }
         Render::set_view('New_Thread_View');
         Render::render([
             'board'  => $board,
@@ -56,6 +59,9 @@ final class Thread_Controller {
         if (!$board) {
             Render::render_error(Error_Type::NOT_FOUND);
         }
+        if (!can_post_in($current_user, $board)) {
+            Render::render_error(Error_Type::FORBIDDEN);
+        }
 
         $values = [
             'title' => trim($_POST['title'] ?? ''),
@@ -106,6 +112,9 @@ final class Thread_Controller {
         if ($thread['locked'] && !role_of($current_user)->is_mod()) {
             Render::render_error(Error_Type::FORBIDDEN);
         }
+        if (!can_post_in($current_user, ['post_min_role' => $thread['board_post_min_role'] ?? 'normal'])) {
+            Render::render_error(Error_Type::FORBIDDEN);
+        }
 
         $body = trim($_POST['body'] ?? '');
         if (($_POST['action'] ?? '') === 'preview') {
diff --git a/lib/db.php b/lib/db.php
index 18fae5d..cbf8409 100644
--- a/lib/db.php
+++ b/lib/db.php
@@ -51,7 +51,8 @@ class Db {
                 name        TEXT NOT NULL,
                 description TEXT NOT NULL DEFAULT '',
                 category    TEXT NOT NULL DEFAULT 'General',
-                position    INTEGER NOT NULL DEFAULT 0
+                position    INTEGER NOT NULL DEFAULT 0,
+                post_min_role TEXT NOT NULL DEFAULT 'normal'
             );
 
             CREATE TABLE IF NOT EXISTS threads (
@@ -83,6 +84,17 @@ class Db {
         self::ensure_column('threads', 'pinned', "INTEGER NOT NULL DEFAULT 0");
         self::ensure_column('threads', 'locked', "INTEGER NOT NULL DEFAULT 0");
 
+        // per-board posting permission (normal < mod < admin). on a db that
+        // predates this column, lock the staff boards down once, on add.
+        $had_post_min_role = self::column_exists('boards', 'post_min_role');
+        self::ensure_column('boards', 'post_min_role', "TEXT NOT NULL DEFAULT 'normal'");
+        if (!$had_post_min_role) {
+            $dbh->exec(
+                "UPDATE boards SET post_min_role = 'admin'
+                  WHERE slug IN ('announcements', 'rules') AND post_min_role = 'normal'"
+            );
+        }
+
         self::seed_boards();
         self::build_search();
     }
@@ -103,27 +115,28 @@ class Db {
             return;
         }
 
-        // [category, slug, glyph + name, description, position]
+        // [category, slug, glyph + name, description, position, post_min_role]
+        // announcements + rules are staff-only (admin); everything else is open.
         $boards = [
-            ['administrative', 'announcements',     "\u{f0a1} announcements",     'official news & site updates',                0],
-            ['administrative', 'rules',             "\u{f0e3} rules",             'read before posting',                         1],
-            ['administrative', 'tutorial-requests', "\u{f02d} tutorial requests", 'request or post guides & howtos',             2],
-            ['the distro wars', 'arch',             "\u{f303} arch",              'arch & arch-based discussion',               10],
-            ['the distro wars', 'nixos',            "\u{f313} nixos",             'nixos & flakes discussion',                  11],
-            ['the distro wars', 'gentoo',           "\u{f30d} gentoo",            'gentoo & source-based discussion',           12],
-            ['the distro wars', 'gnu-guix',         "\u{f325} gnu guix",          'gnu guix discussion',                        13],
-            ['the lounge', 'general',               "\u{f086} general",           'general linux & tech chat',                  20],
-            ['the lounge', 'the-code-review',       "\u{f121} the code review",   'share code, get reviews & talk programming', 21],
-            ['other', 'post-your-rice',             "\u{f108} post your rice",    'show off your desktop, dotfiles & themes',   30],
-            ['other', 'tech-support',               "\u{f1cd} tech support",      'broke something? get help here',             31],
-            ['other', 'off-topic',                  "\u{f0f4} off-topic",         "everything that isn't linux",                32],
+            ['administrative', 'announcements',     "\u{f0a1} announcements",     'official news & site updates',                0, 'admin'],
+            ['administrative', 'rules',             "\u{f0e3} rules",             'read before posting',                         1, 'admin'],
+            ['administrative', 'tutorial-requests', "\u{f02d} tutorial requests", 'request or post guides & howtos',             2, 'normal'],
+            ['the distro wars', 'arch',             "\u{f303} arch",              'arch & arch-based discussion',               10, 'normal'],
+            ['the distro wars', 'nixos',            "\u{f313} nixos",             'nixos & flakes discussion',                  11, 'normal'],
+            ['the distro wars', 'gentoo',           "\u{f30d} gentoo",            'gentoo & source-based discussion',           12, 'normal'],
+            ['the distro wars', 'gnu-guix',         "\u{f325} gnu guix",          'gnu guix discussion',                        13, 'normal'],
+            ['the lounge', 'general',               "\u{f086} general",           'general linux & tech chat',                  20, 'normal'],
+            ['the lounge', 'the-code-review',       "\u{f121} the code review",   'share code, get reviews & talk programming', 21, 'normal'],
+            ['other', 'post-your-rice',             "\u{f108} post your rice",    'show off your desktop, dotfiles & themes',   30, 'normal'],
+            ['other', 'tech-support',               "\u{f1cd} tech support",      'broke something? get help here',             31, 'normal'],
+            ['other', 'off-topic',                  "\u{f0f4} off-topic",         "everything that isn't linux",                32, 'normal'],
         ];
 
         $sth = $dbh->prepare(
-            "INSERT INTO boards (slug, name, description, category, position) VALUES (?, ?, ?, ?, ?)"
+            "INSERT INTO boards (slug, name, description, category, position, post_min_role) VALUES (?, ?, ?, ?, ?, ?)"
         );
-        foreach ($boards as [$category, $slug, $name, $description, $position]) {
-            $sth->execute([$slug, $name, $description, $category, $position]);
+        foreach ($boards as [$category, $slug, $name, $description, $position, $post_min_role]) {
+            $sth->execute([$slug, $name, $description, $category, $position, $post_min_role]);
         }
     }
 
@@ -137,14 +150,25 @@ class Db {
      * @return void
      */
     public static function ensure_column(string $table, string $column, string $definition) : void {
-        $dbh = self::handle();
-        $sth = $dbh->prepare("SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?");
-        $sth->execute([$table, $column]);
-        if ((int) $sth->fetchColumn() === 0) {
-            $dbh->exec("ALTER TABLE $table ADD COLUMN $column $definition");
+        if (!self::column_exists($table, $column)) {
+            self::handle()->exec("ALTER TABLE $table ADD COLUMN $column $definition");
         }
     }
 
+    /**
+     * this function reports whether a table already has a given column, used to
+     * tell a brand-new column apart from an existing one during migration
+     *
+     * @param string $table
+     * @param string $column
+     * @return bool
+     */
+    public static function column_exists(string $table, string $column) : bool {
+        $sth = self::handle()->prepare("SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?");
+        $sth->execute([$table, $column]);
+        return (int) $sth->fetchColumn() > 0;
+    }
+
     /**
      * this function sets up the fts5 full text index over threads + posts. it
      * creates the virtual table, the triggers that keep it in sync as posts are
diff --git a/lib/helpers.php b/lib/helpers.php
index c226d33..cbdc73d 100644
--- a/lib/helpers.php
+++ b/lib/helpers.php
@@ -23,6 +23,29 @@ function role_of(?array $user) : Role {
     return Role::tryFrom($user['role'] ?? 'normal') ?? Role::Normal;
 }
 
+/**
+ * this function returns the minimum role allowed to post in a board, defaulting
+ * to normal (anyone logged in) for boards without the column or an odd value
+ *
+ * @param array $board
+ * @return Role
+ */
+function board_post_min_role(array $board) : Role {
+    return Role::tryFrom($board['post_min_role'] ?? 'normal') ?? Role::Normal;
+}
+
+/**
+ * this function says whether a user may start threads / reply in a board, based
+ * on the board's minimum posting role
+ *
+ * @param array|null $user
+ * @param array $board
+ * @return bool
+ */
+function can_post_in(?array $user, array $board) : bool {
+    return role_of($user)->meets(board_post_min_role($board));
+}
+
 /**
  * this function returns the role badge span for a role string (view helper)
  *
diff --git a/models/Boards_Model.php b/models/Boards_Model.php
index e48727b..bce6c8a 100644
--- a/models/Boards_Model.php
+++ b/models/Boards_Model.php
@@ -98,14 +98,15 @@ final class Boards_Model {
      * @param string $description
      * @param string $category
      * @param int $position
+     * @param string $post_min_role
      * @return int
      */
-    public static function create(string $slug, string $name, string $description, string $category, int $position) : int {
+    public static function create(string $slug, string $name, string $description, string $category, int $position, string $post_min_role = 'normal') : int {
         $dbh = Db::handle();
         $sth = $dbh->prepare(
-            "INSERT INTO boards (slug, name, description, category, position) VALUES (?, ?, ?, ?, ?)"
+            "INSERT INTO boards (slug, name, description, category, position, post_min_role) VALUES (?, ?, ?, ?, ?, ?)"
         );
-        $sth->execute([$slug, $name, $description, $category, $position]);
+        $sth->execute([$slug, $name, $description, $category, $position, $post_min_role]);
         return (int) $dbh->lastInsertId();
     }
 
@@ -118,13 +119,14 @@ final class Boards_Model {
      * @param string $description
      * @param string $category
      * @param int $position
+     * @param string $post_min_role
      * @return void
      */
-    public static function update(int $id, string $slug, string $name, string $description, string $category, int $position) : void {
+    public static function update(int $id, string $slug, string $name, string $description, string $category, int $position, string $post_min_role = 'normal') : void {
         $sth = Db::handle()->prepare(
-            "UPDATE boards SET slug = ?, name = ?, description = ?, category = ?, position = ? WHERE id = ?"
+            "UPDATE boards SET slug = ?, name = ?, description = ?, category = ?, position = ?, post_min_role = ? WHERE id = ?"
         );
-        $sth->execute([$slug, $name, $description, $category, $position, $id]);
+        $sth->execute([$slug, $name, $description, $category, $position, $post_min_role, $id]);
     }
 
     /**
diff --git a/models/Threads_Model.php b/models/Threads_Model.php
index 05b75f1..b7a28de 100644
--- a/models/Threads_Model.php
+++ b/models/Threads_Model.php
@@ -11,7 +11,8 @@ final class Threads_Model {
      */
     public static function get(int $id) : ?array {
         $sql = <<<SQL
-            SELECT t.*, u.username, b.slug AS board_slug, b.name AS board_name
+            SELECT t.*, u.username, b.slug AS board_slug, b.name AS board_name,
+                   b.post_min_role AS board_post_min_role
             FROM threads t
             JOIN users u ON u.id = t.user_id
             LEFT JOIN boards b ON b.id = t.board_id
diff --git a/types/Role.php b/types/Role.php
index 39a5ada..8ebc16b 100644
--- a/types/Role.php
+++ b/types/Role.php
@@ -23,6 +23,30 @@ enum Role: string {
         return $this === self::Admin;
     }
 
+    /**
+     * this function returns the role's privilege rank, so roles can be compared
+     * (normal < mod < admin) for things like per-board posting permissions
+     *
+     * @return int
+     */
+    public function rank() : int {
+        return match ($this) {
+            self::Admin  => 2,
+            self::Mod    => 1,
+            self::Normal => 0,
+        };
+    }
+
+    /**
+     * this function says whether this role is at least as privileged as another
+     *
+     * @param Role $required
+     * @return bool
+     */
+    public function meets(Role $required) : bool {
+        return $this->rank() >= $required->rank();
+    }
+
     /**
      * this function returns the little badge span for a role (empty for normal)
      *
diff --git a/views/Admin_View.php b/views/Admin_View.php
index 0c257fe..cca0e67 100644
--- a/views/Admin_View.php
+++ b/views/Admin_View.php
@@ -53,6 +53,7 @@
             <th>Name</th>
             <th>Description</th>
             <th>Category</th>
+            <th>Posting</th>
             <th>Pos</th>
             <th>Threads</th>
             <th></th>
@@ -65,6 +66,13 @@
             <td><input form="board-<?= (int) $b['id'] ?>" type="text" name="name" value="<?= esc($b['name']) ?>" required></td>
             <td><input form="board-<?= (int) $b['id'] ?>" type="text" name="description" value="<?= esc($b['description']) ?>"></td>
             <td><input form="board-<?= (int) $b['id'] ?>" type="text" name="category" value="<?= esc($b['category']) ?>"></td>
+            <td>
+                <select form="board-<?= (int) $b['id'] ?>" name="post_min_role">
+                    <option value="normal" <?= ($b['post_min_role'] ?? 'normal') === 'normal' ? 'selected' : '' ?>>anyone</option>
+                    <option value="mod"    <?= ($b['post_min_role'] ?? '') === 'mod'    ? 'selected' : '' ?>>mods+</option>
+                    <option value="admin"  <?= ($b['post_min_role'] ?? '') === 'admin'  ? 'selected' : '' ?>>admins</option>
+                </select>
+            </td>
             <td><input form="board-<?= (int) $b['id'] ?>" type="number" name="position" value="<?= (int) $b['position'] ?>" class="pos"></td>
             <td class="muted"><?= (int) $b['thread_count'] ?></td>
             <td class="board-actions">
@@ -86,6 +94,13 @@
             <td><input form="board-new" type="text" name="name" placeholder="name" required></td>
             <td><input form="board-new" type="text" name="description" placeholder="description"></td>
             <td><input form="board-new" type="text" name="category" placeholder="general"></td>
+            <td>
+                <select form="board-new" name="post_min_role">
+                    <option value="normal">anyone</option>
+                    <option value="mod">mods+</option>
+                    <option value="admin">admins</option>
+                </select>
+            </td>
             <td><input form="board-new" type="number" name="position" value="<?= count($boards) ?>" class="pos"></td>
             <td class="muted">—</td>
             <td class="board-actions">
diff --git a/views/Board_View.php b/views/Board_View.php
index 9f236eb..a32f1ab 100644
--- a/views/Board_View.php
+++ b/views/Board_View.php
@@ -6,11 +6,15 @@
 <div class="board-header">
     <h1><?= esc($board['name']) ?></h1>
     <p class="board-desc"><?= esc($board['description']) ?></p>
+    <?php if (can_post_in($current_user, $board)): ?>
     <a href="/board/<?= esc($board['slug']) ?>/new" class="button-link">New thread</a>
+    <?php elseif (board_post_min_role($board) !== Role::Normal): ?>
+    <span class="muted">posting here is restricted to <?= esc(board_post_min_role($board)->value) ?>s</span>
+    <?php endif; ?>
 </div>
 
 <?php if (empty($threads)): ?>
-    <p class="empty">No threads in this board yet. <a href="/board/<?= esc($board['slug']) ?>/new">Start one.</a></p>
+    <p class="empty">No threads in this board yet.<?php if (can_post_in($current_user, $board)): ?> <a href="/board/<?= esc($board['slug']) ?>/new">Start one.</a><?php endif; ?></p>
 <?php else: ?>
 <table class="thread-list">
     <thead>
diff --git a/views/Thread_View.php b/views/Thread_View.php
index afbe4f1..23e00ba 100644
--- a/views/Thread_View.php
+++ b/views/Thread_View.php
@@ -64,6 +64,8 @@
 
 <?php if ($thread['locked'] && !role_of($current_user)->is_mod()): ?>
     <p class="login-prompt">This thread is locked. New replies are disabled.</p>
+<?php elseif ($current_user && !can_post_in($current_user, ['post_min_role' => $thread['board_post_min_role'] ?? 'normal'])): ?>
+    <p class="login-prompt">Posting in this board is restricted to <?= esc($thread['board_post_min_role'] ?? 'normal') ?>s.</p>
 <?php elseif ($current_user): ?>
 <?php if (!empty($reply_preview ?? null)): ?>
 <div class="preview">