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">