forums.tonybtw.com

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

asdf

Commit
26a626d621766a2f51e2ca090f3d5f443e191e02
Author
tonybanters <tonyoutoften@gmail.com>
Date
2026-06-05 17:33:49

Diff

diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..3550a30
--- /dev/null
+++ b/.envrc
@@ -0,0 +1 @@
+use flake
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a48ac2e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+data/
+.direnv/
+notes/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4fa0448
--- /dev/null
+++ b/README.md
@@ -0,0 +1,69 @@
+# 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.
+
+Tokyo Night palette, Iosevka mono — matches my Alacritty/nvim.
+
+## Run
+
+```sh
+just dev          # php -S localhost:8889 -t public
+just dev 3000     # custom port
+just reset-db     # wipe the local sqlite db
+```
+
+The database is created automatically at `data/forum.db` (override with `FORUM_DB`). Schema is applied on first connection.
+
+Structure follows the classic phpBB / Gentoo-forums index (categories → boards with topic/post counts + last-post column), which is also roughly the GameFAQs board-list layout.
+
+## Features
+
+- 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`)
+  - mods & admins can pin, lock, and delete threads, and delete replies
+  - locked threads block replies for normal users
+  - 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
+- 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.
+  - 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.
+
+## Layout
+
+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.
+
+```
+public/index.php       thin front controller (match route -> call controller fn)
+config/paths.php       PATH_TO_* constants
+config/init.php        opens global $dbh, runs migrations, boots session, sets $current_user
+config/routes.php      path-regex -> controller function name
+lib/db.php             db_connect() / db_migrate() (global $dbh)
+lib/session.php        session_boot(), auth(), csrf_token()/check_csrf()
+lib/render.php         set_view() / render() / render_json() / require_login()/_mod()/_admin()
+lib/helpers.php        esc / redirect / time_ago / role_badge / format_body
+lib/Highlight.php      Hl syntax highlighter (C);  lib/Parsedown.php  Markdown
+types/Role.php         enum Role { is_mod(), is_admin(), badge() }
+models/*.php           free functions, global $dbh, heredoc SQL (board/thread/post/user/search)
+controllers/*.php      free functions: validate -> call models -> set_view/render
+views/*_View.php       plain PHP templates; partials included via PATH_TO_VIEWS_DIR
+```
+
+Request flow: `public/index.php` matches the path against `config/routes.php`,
+sets `$route`/`$params`, and calls the controller function, which ends in
+`render()` (which `include`s the `*_View.php`) or `redirect()`.
+
+Conventions: `snake_case` functions/variables, `Upper_Snake` classes/types/enums,
+lowercase `/** this function … */` docblocks, heredoc SQL, `$dbh`/`$sth` handles.
diff --git a/config/init.php b/config/init.php
new file mode 100644
index 0000000..cac285b
--- /dev/null
+++ b/config/init.php
@@ -0,0 +1,43 @@
+<?php
+
+global $dbh, $current_user, $route, $params, $current_view;
+
+spl_autoload_register(function (string $class) : void {
+    static $lib = [
+        'Db'        => 'db.php',
+        'Session'   => 'session.php',
+        'Render'    => 'render.php',
+        'Upload'    => 'upload.php',
+        'Markup'    => 'markup.php',
+        'Parsedown' => 'Parsedown.php',
+    ];
+    if (isset($lib[$class])) {
+        require_once PATH_TO_LIB_DIR . $lib[$class];
+        return;
+    }
+    foreach ([PATH_TO_TYPES_DIR, PATH_TO_MODELS_DIR, PATH_TO_CONTROLLERS_DIR, PATH_TO_VIEWS_DIR] as $dir) {
+        $file = $dir . $class . '.php';
+        if (is_file($file)) {
+            require_once $file;
+            return;
+        }
+    }
+});
+
+require_once PATH_TO_LIB_DIR . 'Highlight.php';
+require_once PATH_TO_LIB_DIR . 'helpers.php';
+
+$dbh = Db::handle();
+Db::migrate();
+
+$admin_user = $_SERVER['FORUM_ADMIN_USER'] ?? getenv('FORUM_ADMIN_USER') ?: '';
+$admin_hash_file = $_SERVER['FORUM_ADMIN_HASH_FILE'] ?? getenv('FORUM_ADMIN_HASH_FILE') ?: '';
+if ($admin_user !== '' && $admin_hash_file !== '' && is_readable($admin_hash_file) && Users_Model::count() === 0) {
+    $hash = trim((string) file_get_contents($admin_hash_file));
+    if ($hash !== '') {
+        Users_Model::create($admin_user, $hash, 'admin');
+    }
+}
+
+Session::boot();
+$current_user = Session::auth();
diff --git a/config/paths.php b/config/paths.php
new file mode 100644
index 0000000..f154eb5
--- /dev/null
+++ b/config/paths.php
@@ -0,0 +1,11 @@
+<?php
+
+define('APP_ROOT', dirname(__DIR__));
+define('PATH_TO_CONFIG_DIR', APP_ROOT . '/config/');
+define('PATH_TO_LIB_DIR', APP_ROOT . '/lib/');
+define('PATH_TO_TYPES_DIR', APP_ROOT . '/types/');
+define('PATH_TO_MODELS_DIR', APP_ROOT . '/models/');
+define('PATH_TO_CONTROLLERS_DIR', APP_ROOT . '/controllers/');
+define('PATH_TO_VIEWS_DIR', APP_ROOT . '/views/');
+$uploads_dir = $_SERVER['FORUM_UPLOADS_DIR'] ?? getenv('FORUM_UPLOADS_DIR') ?: (APP_ROOT . '/public/uploads');
+define('PATH_TO_UPLOADS_DIR', rtrim($uploads_dir, '/') . '/');
diff --git a/config/routes.php b/config/routes.php
new file mode 100644
index 0000000..2f36a05
--- /dev/null
+++ b/config/routes.php
@@ -0,0 +1,28 @@
+<?php
+
+return [
+    'GET /'                                => 'Home_Controller::index',
+    'GET /board/(?<slug>[a-z0-9-]+)/new'   => 'Thread_Controller::new_form',
+    'POST /board/(?<slug>[a-z0-9-]+)/new'  => 'Thread_Controller::create',
+    'GET /board/(?<slug>[a-z0-9-]+)'       => 'Board_Controller::view',
+    'GET /thread/(?<id>\d+)'               => 'Thread_Controller::view',
+    'POST /thread/(?<id>\d+)/reply'        => 'Thread_Controller::reply',
+    'POST /thread/(?<id>\d+)/pin'          => 'Thread_Controller::pin',
+    'POST /thread/(?<id>\d+)/lock'         => 'Thread_Controller::lock',
+    'POST /thread/(?<id>\d+)/delete'       => 'Thread_Controller::delete',
+    'POST /post/(?<id>\d+)/delete'         => 'Post_Controller::delete',
+    'GET /search'                          => 'Search_Controller::index',
+    'GET /upload'                          => 'Upload_Controller::form',
+    'POST /upload'                         => 'Upload_Controller::submit',
+    'GET /admin'                           => 'Admin_Controller::index',
+    'POST /admin/user/(?<id>\d+)/role'     => 'Admin_Controller::set_role',
+    'POST /admin/board'                    => 'Admin_Controller::create_board',
+    'POST /admin/board/(?<id>\d+)/delete'  => 'Admin_Controller::delete_board',
+    'POST /admin/board/(?<id>\d+)'         => 'Admin_Controller::update_board',
+    'GET /u/(?<name>[A-Za-z0-9_]+)'        => 'User_Controller::view',
+    'GET /register'                        => 'Register_Controller::form',
+    'POST /register'                       => 'Register_Controller::submit',
+    'GET /login'                           => 'Login_Controller::form',
+    'POST /login'                          => 'Login_Controller::submit',
+    'POST /logout'                         => 'Logout_Controller::submit',
+];
diff --git a/controllers/Admin_Controller.php b/controllers/Admin_Controller.php
new file mode 100644
index 0000000..295d567
--- /dev/null
+++ b/controllers/Admin_Controller.php
@@ -0,0 +1,128 @@
+<?php
+
+final class Admin_Controller {
+
+    /**
+     * this function renders the admin panel: the full user list with roles
+     *
+     * @return never
+     */
+    public static function index() : never {
+        Session::require_admin();
+        Render::set_view('Admin_View');
+        Render::render(['users' => Users_Model::list_all(), 'boards' => Boards_Model::list_all()]);
+    }
+
+    /**
+     * this function sets a user's role (admin only). it refuses an unknown
+     * role and wont let you change your own role, so you cant lock yourself
+     * out
+     *
+     * @param array $params
+     * @return never
+     */
+    public static function set_role(array $params) : never {
+        $current_user = Session::require_admin();
+        Session::require_csrf();
+        $uid  = (int) $params['id'];
+        $role = $_POST['role'] ?? '';
+        if (!in_array($role, ['normal', 'mod', 'admin'], true)) {
+            Render::render_error(Error_Type::BAD_REQUEST);
+        }
+        if ($uid === (int) $current_user['id']) {
+            Render::render_error(Error_Type::BAD_REQUEST);
+        }
+        if (!Users_Model::get($uid)) {
+            Render::render_error(Error_Type::NOT_FOUND);
+        }
+        Users_Model::set_role($uid, $role);
+        redirect('/admin');
+    }
+
+    /**
+     * 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]
+     *
+     * @return array
+     */
+    private static function board_input() : array {
+        $slug        = strtolower(trim($_POST['slug'] ?? ''));
+        $name        = trim($_POST['name'] ?? '');
+        $description = trim($_POST['description'] ?? '');
+        $category    = trim($_POST['category'] ?? '');
+        $position    = (int) ($_POST['position'] ?? 0);
+
+        if (!preg_match('/^[a-z0-9-]+$/', $slug)) {
+            Render::render_error(Error_Type::BAD_REQUEST);
+        }
+        if ($name === '') {
+            Render::render_error(Error_Type::BAD_REQUEST);
+        }
+        if ($category === '') {
+            $category = 'general';
+        }
+        return [$slug, $name, $description, $category, $position];
+    }
+
+    /**
+     * this function creates a new board (admin only), refusing a slug that is
+     * already taken
+     *
+     * @return never
+     */
+    public static function create_board() : never {
+        Session::require_admin();
+        Session::require_csrf();
+        [$slug, $name, $description, $category, $position] = self::board_input();
+        if (Boards_Model::get_by_slug($slug)) {
+            Render::render_error(Error_Type::CONFLICT);
+        }
+        Boards_Model::create($slug, $name, $description, $category, $position);
+        redirect('/admin');
+    }
+
+    /**
+     * this function edits an existing board (admin only). it lets you change
+     * the slug too, but refuses one that collides with a different board
+     *
+     * @param array $params
+     * @return never
+     */
+    public static function update_board(array $params) : never {
+        Session::require_admin();
+        Session::require_csrf();
+        $id = (int) $params['id'];
+        if (!Boards_Model::get($id)) {
+            Render::render_error(Error_Type::NOT_FOUND);
+        }
+        [$slug, $name, $description, $category, $position] = 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);
+        redirect('/admin');
+    }
+
+    /**
+     * this function deletes a board (admin only). it refuses to delete a board
+     * that still has threads, since the foreign key would orphan them
+     *
+     * @param array $params
+     * @return never
+     */
+    public static function delete_board(array $params) : never {
+        Session::require_admin();
+        Session::require_csrf();
+        $id = (int) $params['id'];
+        if (!Boards_Model::get($id)) {
+            Render::render_error(Error_Type::NOT_FOUND);
+        }
+        if (Boards_Model::thread_count($id) > 0) {
+            Render::render_error(Error_Type::BAD_REQUEST);
+        }
+        Boards_Model::delete($id);
+        redirect('/admin');
+    }
+}
diff --git a/controllers/Board_Controller.php b/controllers/Board_Controller.php
new file mode 100644
index 0000000..a43fe22
--- /dev/null
+++ b/controllers/Board_Controller.php
@@ -0,0 +1,30 @@
+<?php
+
+final class Board_Controller {
+
+    /**
+     * this function renders a board: a paginated list of its threads
+     *
+     * @param array $params
+     * @return never
+     */
+    public static function view(array $params) : never {
+        $board = Boards_Model::get_by_slug($params['slug']);
+        if (!$board) {
+            Render::render_error(Error_Type::NOT_FOUND);
+        }
+        $page     = max(1, (int) ($_GET['page'] ?? 1));
+        $per_page = 40;
+        $threads  = Threads_Model::list_in_board($board['id'], $per_page, ($page - 1) * $per_page);
+        $total    = Boards_Model::thread_count($board['id']);
+
+        Render::set_view('Board_View');
+        Render::render([
+            'board'    => $board,
+            'threads'  => $threads,
+            'total'    => $total,
+            'page'     => $page,
+            'per_page' => $per_page,
+        ]);
+    }
+}
diff --git a/controllers/Home_Controller.php b/controllers/Home_Controller.php
new file mode 100644
index 0000000..0faf39c
--- /dev/null
+++ b/controllers/Home_Controller.php
@@ -0,0 +1,14 @@
+<?php
+
+final class Home_Controller {
+
+    /**
+     * this function renders the forum index: every board grouped by category
+     *
+     * @return never
+     */
+    public static function index() : never {
+        Render::set_view('Home_View');
+        Render::render(['categories' => Boards_Model::list_grouped()]);
+    }
+}
diff --git a/controllers/Login_Controller.php b/controllers/Login_Controller.php
new file mode 100644
index 0000000..9588a37
--- /dev/null
+++ b/controllers/Login_Controller.php
@@ -0,0 +1,39 @@
+<?php
+
+final class Login_Controller {
+
+    /**
+     * this function shows the login form (or bounces you home if already
+     * logged in)
+     *
+     * @return never
+     */
+    public static function form() : never {
+        global $current_user;
+        if ($current_user) {
+            redirect('/');
+        }
+        Render::set_view('Login_View');
+        Render::render(['errors' => [], 'values' => ['username' => '']]);
+    }
+
+    /**
+     * this function handles a login submit, checking the password and
+     * starting a session on success
+     *
+     * @return never
+     */
+    public static function submit() : never {
+        Session::require_csrf();
+        $values   = ['username' => trim($_POST['username'] ?? '')];
+        $password = (string) ($_POST['password'] ?? '');
+
+        $user = Users_Model::get_by_name($values['username']);
+        if (!$user || !password_verify($password, $user['password_hash'])) {
+            Render::set_view('Login_View');
+            Render::render(['errors' => ['Invalid username or password.'], 'values' => $values]);
+        }
+        Session::login($user);
+        redirect('/');
+    }
+}
diff --git a/controllers/Logout_Controller.php b/controllers/Logout_Controller.php
new file mode 100644
index 0000000..cb8fb15
--- /dev/null
+++ b/controllers/Logout_Controller.php
@@ -0,0 +1,15 @@
+<?php
+
+final class Logout_Controller {
+
+    /**
+     * this function logs the current user out
+     *
+     * @return never
+     */
+    public static function submit() : never {
+        Session::require_csrf();
+        Session::logout();
+        redirect('/');
+    }
+}
diff --git a/controllers/Post_Controller.php b/controllers/Post_Controller.php
new file mode 100644
index 0000000..a603bdd
--- /dev/null
+++ b/controllers/Post_Controller.php
@@ -0,0 +1,27 @@
+<?php
+
+final class Post_Controller {
+
+    /**
+     * this function deletes a single post (mods only). deleting the OP deletes
+     * the whole thread instead, since a thread cant be left without its first
+     * post
+     *
+     * @param array $params
+     * @return never
+     */
+    public static function delete(array $params) : never {
+        Session::require_mod();
+        Session::require_csrf();
+        $post = Posts_Model::get((int) $params['id']);
+        if (!$post) {
+            Render::render_error(Error_Type::NOT_FOUND);
+        }
+        if (Posts_Model::first_id($post['thread_id']) === (int) $post['id']) {
+            Threads_Model::delete($post['thread_id']);
+            redirect('/');
+        }
+        Posts_Model::delete($post['id']);
+        redirect('/thread/' . $post['thread_id']);
+    }
+}
diff --git a/controllers/Register_Controller.php b/controllers/Register_Controller.php
new file mode 100644
index 0000000..12754cb
--- /dev/null
+++ b/controllers/Register_Controller.php
@@ -0,0 +1,56 @@
+<?php
+
+final class Register_Controller {
+
+    /**
+     * this function shows the register form (or bounces you home if logged
+     * in)
+     *
+     * @return never
+     */
+    public static function form() : never {
+        global $current_user;
+        if ($current_user) {
+            redirect('/');
+        }
+        Render::set_view('Register_View');
+        Render::render(['errors' => [], 'values' => ['username' => '']]);
+    }
+
+    /**
+     * this function handles a register submit. it validates the username and
+     * password, makes the very first account an admin, and logs the new user
+     * in
+     *
+     * @return never
+     */
+    public static function submit() : never {
+        Session::require_csrf();
+        $values   = ['username' => trim($_POST['username'] ?? '')];
+        $password = (string) ($_POST['password'] ?? '');
+        $confirm  = (string) ($_POST['confirm']  ?? '');
+        $errors   = [];
+
+        if (!preg_match('/^[A-Za-z0-9_]{3,20}$/', $values['username'])) {
+            $errors[] = 'Username must be 3-20 characters: letters, numbers, underscore.';
+        } elseif (Users_Model::get_by_name($values['username'])) {
+            $errors[] = 'That username is taken.';
+        }
+        if (mb_strlen($password) < 6) {
+            $errors[] = 'Password must be at least 6 characters.';
+        }
+        if ($password !== $confirm) {
+            $errors[] = 'Passwords do not match.';
+        }
+        if ($errors) {
+            Render::set_view('Register_View');
+            Render::render(['errors' => $errors, 'values' => $values]);
+        }
+
+        $role = Users_Model::count() === 0 ? 'admin' : 'normal';
+        $hash = password_hash($password, PASSWORD_DEFAULT);
+        $id   = Users_Model::create($values['username'], $hash, $role);
+        Session::login(['id' => $id]);
+        redirect('/');
+    }
+}
diff --git a/controllers/Search_Controller.php b/controllers/Search_Controller.php
new file mode 100644
index 0000000..40dfc48
--- /dev/null
+++ b/controllers/Search_Controller.php
@@ -0,0 +1,23 @@
+<?php
+
+final class Search_Controller {
+
+    /**
+     * this function runs a search. with ?json=1 it returns the results as
+     * json (the telescope modal uses that), otherwise it renders the search
+     * page
+     *
+     * @return never
+     */
+    public static function index() : never {
+        $q       = trim($_GET['q'] ?? '');
+        $results = $q !== '' ? Search_Model::search_posts($q) : [];
+
+        if (isset($_GET['json'])) {
+            Render::render_json($results);
+        }
+
+        Render::set_view('Search_View');
+        Render::render(['q' => $q, 'results' => $results]);
+    }
+}
diff --git a/controllers/Thread_Controller.php b/controllers/Thread_Controller.php
new file mode 100644
index 0000000..c53e2b1
--- /dev/null
+++ b/controllers/Thread_Controller.php
@@ -0,0 +1,180 @@
+<?php
+
+final class Thread_Controller {
+
+    /**
+     * this function renders a thread and all of its posts
+     *
+     * @param array $params
+     * @return never
+     */
+    public static function view(array $params) : never {
+        $thread = Threads_Model::get((int) $params['id']);
+        if (!$thread) {
+            Render::render_error(Error_Type::NOT_FOUND);
+        }
+        Render::set_view('Thread_View');
+        Render::render([
+            'thread'        => $thread,
+            'posts'         => Posts_Model::list_for_thread($thread['id']),
+            'first_post_id' => Posts_Model::first_id($thread['id']),
+        ]);
+    }
+
+    /**
+     * this function shows the new-thread form for a board
+     *
+     * @param array $params
+     * @return never
+     */
+    public static function new_form(array $params) : never {
+        Session::require_login();
+        $board = Boards_Model::get_by_slug($params['slug']);
+        if (!$board) {
+            Render::render_error(Error_Type::NOT_FOUND);
+        }
+        Render::set_view('New_Thread_View');
+        Render::render([
+            'board'  => $board,
+            'errors' => [],
+            'values' => ['title' => '', 'body' => ''],
+        ]);
+    }
+
+    /**
+     * this function handles the new-thread submit. it previews when asked,
+     * validates the title + body, and otherwise creates the thread and
+     * redirects
+     *
+     * @param array $params
+     * @return never
+     */
+    public static function create(array $params) : never {
+        $current_user = Session::require_login();
+        Session::require_csrf();
+        $board = Boards_Model::get_by_slug($params['slug']);
+        if (!$board) {
+            Render::render_error(Error_Type::NOT_FOUND);
+        }
+
+        $values = [
+            'title' => trim($_POST['title'] ?? ''),
+            'body'  => trim($_POST['body']  ?? ''),
+        ];
+        $errors = [];
+
+        if (($_POST['action'] ?? '') === 'preview') {
+            Render::set_view('New_Thread_View');
+            Render::render([
+                'board'   => $board,
+                'errors'  => $errors,
+                'values'  => $values,
+                'preview' => Markup::format($values['body']),
+            ]);
+        }
+
+        if (mb_strlen($values['title']) < 3 || mb_strlen($values['title']) > 200) {
+            $errors[] = 'Title must be between 3 and 200 characters.';
+        }
+        if ($values['body'] === '') {
+            $errors[] = 'Post body cannot be empty.';
+        }
+        if ($errors) {
+            Render::set_view('New_Thread_View');
+            Render::render(['board' => $board, 'errors' => $errors, 'values' => $values]);
+        }
+
+        $id = Threads_Model::create($board['id'], $current_user['id'], $values['title'], $values['body']);
+        redirect('/thread/' . $id);
+    }
+
+    /**
+     * this function handles a reply submit. it previews when asked, blocks
+     * replies on locked threads (unless youre a mod), and otherwise posts and
+     * redirects
+     *
+     * @param array $params
+     * @return never
+     */
+    public static function reply(array $params) : never {
+        $current_user = Session::require_login();
+        Session::require_csrf();
+        $thread = Threads_Model::get((int) $params['id']);
+        if (!$thread) {
+            Render::render_error(Error_Type::NOT_FOUND);
+        }
+        if ($thread['locked'] && !role_of($current_user)->is_mod()) {
+            Render::render_error(Error_Type::FORBIDDEN);
+        }
+
+        $body = trim($_POST['body'] ?? '');
+        if (($_POST['action'] ?? '') === 'preview') {
+            Render::set_view('Thread_View');
+            Render::render([
+                'thread'        => $thread,
+                'posts'         => Posts_Model::list_for_thread($thread['id']),
+                'first_post_id' => Posts_Model::first_id($thread['id']),
+                'reply_draft'   => $body,
+                'reply_preview' => Markup::format($body),
+            ]);
+        }
+        if ($body === '') {
+            redirect('/thread/' . $thread['id']);
+        }
+        Posts_Model::create($thread['id'], $current_user['id'], $body);
+        redirect('/thread/' . $thread['id'] . '#bottom');
+    }
+
+    /**
+     * this function toggles a thread's pinned flag (mods only)
+     *
+     * @param array $params
+     * @return never
+     */
+    public static function pin(array $params) : never {
+        Session::require_mod();
+        Session::require_csrf();
+        $thread = Threads_Model::get((int) $params['id']);
+        if (!$thread) {
+            Render::render_error(Error_Type::NOT_FOUND);
+        }
+        Threads_Model::set_pinned($thread['id'], !$thread['pinned']);
+        redirect('/thread/' . $thread['id']);
+    }
+
+    /**
+     * this function toggles a thread's locked flag (mods only)
+     *
+     * @param array $params
+     * @return never
+     */
+    public static function lock(array $params) : never {
+        Session::require_mod();
+        Session::require_csrf();
+        $thread = Threads_Model::get((int) $params['id']);
+        if (!$thread) {
+            Render::render_error(Error_Type::NOT_FOUND);
+        }
+        Threads_Model::set_locked($thread['id'], !$thread['locked']);
+        redirect('/thread/' . $thread['id']);
+    }
+
+    /**
+     * this function deletes a whole thread (mods only) and sends you back to
+     * the board it was on
+     *
+     * @param array $params
+     * @return never
+     */
+    public static function delete(array $params) : never {
+        Session::require_mod();
+        Session::require_csrf();
+        $thread = Threads_Model::get((int) $params['id']);
+        if (!$thread) {
+            Render::render_error(Error_Type::NOT_FOUND);
+        }
+        $back = $thread['board_slug'] ? '/board/' . $thread['board_slug'] : '/';
+        Threads_Model::delete($thread['id']);
+        redirect($back);
+    }
+}
diff --git a/controllers/Upload_Controller.php b/controllers/Upload_Controller.php
new file mode 100644
index 0000000..4592e2b
--- /dev/null
+++ b/controllers/Upload_Controller.php
@@ -0,0 +1,41 @@
+<?php
+
+final class Upload_Controller {
+
+    /**
+     * this function shows the no-js upload page
+     *
+     * @return never
+     */
+    public static function form() : never {
+        Session::require_login();
+        Render::set_view('Upload_View');
+        Render::render(['errors' => [], 'result' => null]);
+    }
+
+    /**
+     * this function handles an image upload. with ?json=1 it returns json
+     * (the paste/drag handler uses that), otherwise it re-renders the upload
+     * page with the resulting markdown snippet or an error
+     *
+     * @return never
+     */
+    public static function submit() : never {
+        Session::require_login();
+        Session::require_csrf();
+        $res = Upload::save($_FILES['image'] ?? []);
+
+        if (isset($_GET['json'])) {
+            if (isset($res['error'])) {
+                Render::render_json(['error' => $res['error']], 422);
+            }
+            Render::render_json($res);
+        }
+
+        Render::set_view('Upload_View');
+        Render::render([
+            'errors' => isset($res['error']) ? [$res['error']] : [],
+            'result' => $res['url'] ?? null,
+        ]);
+    }
+}
diff --git a/controllers/User_Controller.php b/controllers/User_Controller.php
new file mode 100644
index 0000000..72dd240
--- /dev/null
+++ b/controllers/User_Controller.php
@@ -0,0 +1,23 @@
+<?php
+
+final class User_Controller {
+
+    /**
+     * this function renders a user's profile: their role, totals, and threads
+     *
+     * @param array $params
+     * @return never
+     */
+    public static function view(array $params) : never {
+        $user = Users_Model::get_by_name($params['name']);
+        if (!$user) {
+            Render::render_error(Error_Type::NOT_FOUND);
+        }
+        Render::set_view('User_View');
+        Render::render([
+            'user'         => $user,
+            'user_threads' => Threads_Model::list_by_user($user['id']),
+            'stats'        => Users_Model::get_stats($user['id']),
+        ]);
+    }
+}
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..4070242
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,27 @@
+{
+  "nodes": {
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1779560665,
+        "narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixos-unstable",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "nixpkgs": "nixpkgs"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..749590d
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,38 @@
+{
+  description = "forums-btw - A minimal php forum";
+  inputs = {
+    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+  };
+  outputs = {
+    self,
+    nixpkgs,
+  }: let
+    systems = ["x86_64-linux" "aarch64-linux"];
+
+    forAllSystems = fn: nixpkgs.lib.genAttrs systems (system: fn nixpkgs.legacyPackages.${system});
+  in {
+    devShells = forAllSystems (pkgs: {
+      default = pkgs.mkShell {
+        packages = [
+          (pkgs.php.withExtensions ({enabled, all}: enabled ++ [all.pdo_sqlite]))
+          pkgs.just
+          pkgs.sqlite
+        ];
+        shellHook = ''
+          export PS1="(forums-btw) $PS1"
+          echo ""
+          echo "  forums-btw dev server"
+          echo "  ---------------------"
+          echo "  just dev         - start php server on localhost:8889"
+          echo "  just dev 3000    - start on custom port"
+          echo ""
+          echo "  FORUM_DB defaults to ./data/forum.db"
+          echo "  just reset-db    - delete the local database"
+          echo ""
+        '';
+      };
+    });
+
+    formatter = forAllSystems (pkgs: pkgs.alejandra);
+  };
+}
diff --git a/justfile b/justfile
new file mode 100644
index 0000000..52acc4f
--- /dev/null
+++ b/justfile
@@ -0,0 +1,8 @@
+default:
+    @just --list
+
+dev port="8889":
+    FORUM_DB={{justfile_directory()}}/data/forum.db php -d upload_max_filesize=30M -d post_max_size=32M -S localhost:{{port}} -t public
+
+reset-db:
+    rm -f {{justfile_directory()}}/data/forum.db {{justfile_directory()}}/data/forum.db-wal {{justfile_directory()}}/data/forum.db-shm
diff --git a/lib/Highlight.php b/lib/Highlight.php
new file mode 100644
index 0000000..754ce96
--- /dev/null
+++ b/lib/Highlight.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace Hl;
+
+enum Tok: string {
+    case Keyword = 'kw';
+    case Type    = 'ty';
+    case Builtin = 'bi';
+    case Number  = 'num';
+    case String_ = 'str';
+    case Char    = 'chr';
+    case Comment = 'cmt';
+    case Preproc = 'pp';
+    case Op      = 'op';
+    case Punct   = 'pun';
+    case Plain   = 'pl';
+}
+
+final class Registry {
+    public static array $langs = [];
+}
+
+function register(string $lang, callable $tokenizer): void {
+    Registry::$langs[$lang] = $tokenizer;
+}
+
+function highlight(string $lang, string $src): string {
+    $fn = Registry::$langs[$lang] ?? null;
+    if ($fn === null) {
+        return htmlspecialchars($src);
+    }
+    return render($fn($src));
+}
+
+/**
+ * walk a [Tok, string][] token stream and emit html, splitting any token whose
+ * text contains newlines so per-line containers (e.g. table rows in blob view)
+ * stay valid html when the result is split on "\n".
+ */
+function render(array $tokens): string {
+    $out = '';
+    foreach ($tokens as [$tok, $text]) {
+        $lines = explode("\n", $text);
+        $last = count($lines) - 1;
+        foreach ($lines as $i => $part) {
+            if ($part !== '') {
+                $out .= $tok === Tok::Plain
+                    ? htmlspecialchars($part)
+                    : '<span class="t-' . $tok->value . '">' . htmlspecialchars($part) . '</span>';
+            }
+            if ($i < $last) $out .= "\n";
+        }
+    }
+    return $out;
+}
+
+foreach (glob(__DIR__ . '/Highlight/*.php') as $f) {
+    require_once $f;
+}
diff --git a/lib/Highlight/c.php b/lib/Highlight/c.php
new file mode 100644
index 0000000..b635071
--- /dev/null
+++ b/lib/Highlight/c.php
@@ -0,0 +1,181 @@
+<?php
+
+namespace Hl;
+
+const C_KEYWORDS = [
+    'if', 'else', 'while', 'for', 'do', 'switch', 'case', 'default',
+    'break', 'continue', 'return', 'goto', 'sizeof', 'typedef', 'struct',
+    'union', 'enum', 'static', 'extern', 'const', 'volatile', 'register',
+    'inline', 'restrict', 'auto', '_Alignas', '_Alignof', '_Atomic',
+    '_Generic', '_Noreturn', '_Static_assert', '_Thread_local',
+];
+
+const C_TYPES = [
+    'void', 'char', 'short', 'int', 'long', 'float', 'double',
+    'signed', 'unsigned', '_Bool', '_Complex', '_Imaginary',
+    'int8_t', 'int16_t', 'int32_t', 'int64_t',
+    'uint8_t', 'uint16_t', 'uint32_t', 'uint64_t',
+    'size_t', 'ssize_t', 'ptrdiff_t', 'intptr_t', 'uintptr_t',
+    'FILE', 'va_list', 'wchar_t', 'time_t', 'off_t', 'pid_t', 'mode_t',
+];
+
+const C_BUILTINS = [
+    'NULL', 'true', 'false', 'stdin', 'stdout', 'stderr', 'EOF',
+    '__FILE__', '__LINE__', '__func__', '__VA_ARGS__',
+];
+
+const C_OP_CHARS = '+-*/%=<>!&|^~?:';
+const C_PUNCT_CHARS = '(){}[];,.';
+
+/**
+ * scan a C source string into [Tok, text] tuples. Single-pass, no regex.
+ * Preprocessor directives are emitted as one token per logical line, with
+ * line-continuation backslashes folded in so multi-physical-line macros
+ * stay grouped under Tok::Preproc.
+ */
+function tokenize_c(string $src): array {
+    $tokens = [];
+    $i = 0;
+    $n = strlen($src);
+    $at_line_start = true;
+
+    while ($i < $n) {
+        $ch = $src[$i];
+
+        if ($ch === "\n") {
+            $tokens[] = [Tok::Plain, "\n"];
+            $i++;
+            $at_line_start = true;
+            continue;
+        }
+
+        if ($ch === ' ' || $ch === "\t" || $ch === "\r") {
+            $j = $i;
+            while ($j < $n && ($src[$j] === ' ' || $src[$j] === "\t" || $src[$j] === "\r")) $j++;
+            $tokens[] = [Tok::Plain, substr($src, $i, $j - $i)];
+            $i = $j;
+            continue;
+        }
+
+        if ($at_line_start && $ch === '#') {
+            $j = $i;
+            while ($j < $n) {
+                if ($src[$j] === "\n") {
+                    if ($j > 0 && $src[$j - 1] === '\\') { $j++; continue; }
+                    break;
+                }
+                $j++;
+            }
+            $tokens[] = [Tok::Preproc, substr($src, $i, $j - $i)];
+            $i = $j;
+            $at_line_start = false;
+            continue;
+        }
+
+        $at_line_start = false;
+
+        if ($ch === '/' && $i + 1 < $n && $src[$i + 1] === '/') {
+            $j = $i + 2;
+            while ($j < $n && $src[$j] !== "\n") $j++;
+            $tokens[] = [Tok::Comment, substr($src, $i, $j - $i)];
+            $i = $j;
+            continue;
+        }
+
+        if ($ch === '/' && $i + 1 < $n && $src[$i + 1] === '*') {
+            $j = $i + 2;
+            while ($j + 1 < $n && !($src[$j] === '*' && $src[$j + 1] === '/')) $j++;
+            $j = min($j + 2, $n);
+            $tokens[] = [Tok::Comment, substr($src, $i, $j - $i)];
+            $i = $j;
+            continue;
+        }
+
+        if ($ch === '"') {
+            $j = $i + 1;
+            while ($j < $n && $src[$j] !== '"') {
+                if ($src[$j] === '\\' && $j + 1 < $n) { $j += 2; continue; }
+                if ($src[$j] === "\n") break;
+                $j++;
+            }
+            if ($j < $n && $src[$j] === '"') $j++;
+            $tokens[] = [Tok::String_, substr($src, $i, $j - $i)];
+            $i = $j;
+            continue;
+        }
+
+        if ($ch === "'") {
+            $j = $i + 1;
+            while ($j < $n && $src[$j] !== "'") {
+                if ($src[$j] === '\\' && $j + 1 < $n) { $j += 2; continue; }
+                if ($src[$j] === "\n") break;
+                $j++;
+            }
+            if ($j < $n && $src[$j] === "'") $j++;
+            $tokens[] = [Tok::Char, substr($src, $i, $j - $i)];
+            $i = $j;
+            continue;
+        }
+
+        if (ctype_digit($ch) || ($ch === '.' && $i + 1 < $n && ctype_digit($src[$i + 1]))) {
+            $j = $i;
+            if ($ch === '0' && $i + 1 < $n && ($src[$i + 1] === 'x' || $src[$i + 1] === 'X')) {
+                $j = $i + 2;
+                while ($j < $n && (ctype_xdigit($src[$j]) || $src[$j] === '.' || $src[$j] === "'")) $j++;
+                if ($j < $n && ($src[$j] === 'p' || $src[$j] === 'P')) {
+                    $j++;
+                    if ($j < $n && ($src[$j] === '+' || $src[$j] === '-')) $j++;
+                    while ($j < $n && ctype_digit($src[$j])) $j++;
+                }
+            } else {
+                while ($j < $n && (ctype_digit($src[$j]) || $src[$j] === '.' || $src[$j] === "'")) $j++;
+                if ($j < $n && ($src[$j] === 'e' || $src[$j] === 'E')) {
+                    $j++;
+                    if ($j < $n && ($src[$j] === '+' || $src[$j] === '-')) $j++;
+                    while ($j < $n && ctype_digit($src[$j])) $j++;
+                }
+            }
+            while ($j < $n && strpos('uUlLfFzZ', $src[$j]) !== false) $j++;
+            $tokens[] = [Tok::Number, substr($src, $i, $j - $i)];
+            $i = $j;
+            continue;
+        }
+
+        if (ctype_alpha($ch) || $ch === '_') {
+            $j = $i;
+            while ($j < $n && (ctype_alnum($src[$j]) || $src[$j] === '_')) $j++;
+            $word = substr($src, $i, $j - $i);
+            $tok = match (true) {
+                in_array($word, C_KEYWORDS, true) => Tok::Keyword,
+                in_array($word, C_TYPES, true)    => Tok::Type,
+                in_array($word, C_BUILTINS, true) => Tok::Builtin,
+                str_ends_with($word, '_t')        => Tok::Type,
+                default                            => Tok::Plain,
+            };
+            $tokens[] = [$tok, $word];
+            $i = $j;
+            continue;
+        }
+
+        if (strpos(C_OP_CHARS, $ch) !== false) {
+            $j = $i;
+            while ($j < $n && strpos(C_OP_CHARS, $src[$j]) !== false) $j++;
+            $tokens[] = [Tok::Op, substr($src, $i, $j - $i)];
+            $i = $j;
+            continue;
+        }
+
+        if (strpos(C_PUNCT_CHARS, $ch) !== false) {
+            $tokens[] = [Tok::Punct, $ch];
+            $i++;
+            continue;
+        }
+
+        $tokens[] = [Tok::Plain, $ch];
+        $i++;
+    }
+
+    return $tokens;
+}
+
+register('c', __NAMESPACE__ . '\\tokenize_c');
diff --git a/lib/Parsedown.php b/lib/Parsedown.php
new file mode 100644
index 0000000..38edfe9
--- /dev/null
+++ b/lib/Parsedown.php
@@ -0,0 +1,1994 @@
+<?php
+
+#
+#
+# Parsedown
+# http://parsedown.org
+#
+# (c) Emanuil Rusev
+# http://erusev.com
+#
+# For the full license information, view the LICENSE file that was distributed
+# with this source code.
+#
+#
+
+class Parsedown
+{
+    # ~
+
+    const version = '1.8.0';
+
+    # ~
+
+    function text($text)
+    {
+        $Elements = $this->textElements($text);
+
+        # convert to markup
+        $markup = $this->elements($Elements);
+
+        # trim line breaks
+        $markup = trim($markup, "\n");
+
+        return $markup;
+    }
+
+    protected function textElements($text)
+    {
+        # make sure no definitions are set
+        $this->DefinitionData = array();
+
+        # standardize line breaks
+        $text = str_replace(array("\r\n", "\r"), "\n", $text);
+
+        # remove surrounding line breaks
+        $text = trim($text, "\n");
+
+        # split text into lines
+        $lines = explode("\n", $text);
+
+        # iterate through lines to identify blocks
+        return $this->linesElements($lines);
+    }
+
+    #
+    # Setters
+    #
+
+    function setBreaksEnabled($breaksEnabled)
+    {
+        $this->breaksEnabled = $breaksEnabled;
+
+        return $this;
+    }
+
+    protected $breaksEnabled;
+
+    function setMarkupEscaped($markupEscaped)
+    {
+        $this->markupEscaped = $markupEscaped;
+
+        return $this;
+    }
+
+    protected $markupEscaped;
+
+    function setUrlsLinked($urlsLinked)
+    {
+        $this->urlsLinked = $urlsLinked;
+
+        return $this;
+    }
+
+    protected $urlsLinked = true;
+
+    function setSafeMode($safeMode)
+    {
+        $this->safeMode = (bool) $safeMode;
+
+        return $this;
+    }
+
+    protected $safeMode;
+
+    function setStrictMode($strictMode)
+    {
+        $this->strictMode = (bool) $strictMode;
+
+        return $this;
+    }
+
+    protected $strictMode;
+
+    protected $safeLinksWhitelist = array(
+        'http://',
+        'https://',
+        'ftp://',
+        'ftps://',
+        'mailto:',
+        'tel:',
+        'data:image/png;base64,',
+        'data:image/gif;base64,',
+        'data:image/jpeg;base64,',
+        'irc:',
+        'ircs:',
+        'git:',
+        'ssh:',
+        'news:',
+        'steam:',
+    );
+
+    #
+    # Lines
+    #
+
+    protected $BlockTypes = array(
+        '#' => array('Header'),
+        '*' => array('Rule', 'List'),
+        '+' => array('List'),
+        '-' => array('SetextHeader', 'Table', 'Rule', 'List'),
+        '0' => array('List'),
+        '1' => array('List'),
+        '2' => array('List'),
+        '3' => array('List'),
+        '4' => array('List'),
+        '5' => array('List'),
+        '6' => array('List'),
+        '7' => array('List'),
+        '8' => array('List'),
+        '9' => array('List'),
+        ':' => array('Table'),
+        '<' => array('Comment', 'Markup'),
+        '=' => array('SetextHeader'),
+        '>' => array('Quote'),
+        '[' => array('Reference'),
+        '_' => array('Rule'),
+        '`' => array('FencedCode'),
+        '|' => array('Table'),
+        '~' => array('FencedCode'),
+    );
+
+    # ~
+
+    protected $unmarkedBlockTypes = array(
+        'Code',
+    );
+
+    #
+    # Blocks
+    #
+
+    protected function lines(array $lines)
+    {
+        return $this->elements($this->linesElements($lines));
+    }
+
+    protected function linesElements(array $lines)
+    {
+        $Elements = array();
+        $CurrentBlock = null;
+
+        foreach ($lines as $line)
+        {
+            if (chop($line) === '')
+            {
+                if (isset($CurrentBlock))
+                {
+                    $CurrentBlock['interrupted'] = (isset($CurrentBlock['interrupted'])
+                        ? $CurrentBlock['interrupted'] + 1 : 1
+                    );
+                }
+
+                continue;
+            }
+
+            while (($beforeTab = strstr($line, "\t", true)) !== false)
+            {
+                $shortage = 4 - mb_strlen($beforeTab, 'utf-8') % 4;
+
+                $line = $beforeTab
+                    . str_repeat(' ', $shortage)
+                    . substr($line, strlen($beforeTab) + 1)
+                ;
+            }
+
+            $indent = strspn($line, ' ');
+
+            $text = $indent > 0 ? substr($line, $indent) : $line;
+
+            # ~
+
+            $Line = array('body' => $line, 'indent' => $indent, 'text' => $text);
+
+            # ~
+
+            if (isset($CurrentBlock['continuable']))
+            {
+                $methodName = 'block' . $CurrentBlock['type'] . 'Continue';
+                $Block = $this->$methodName($Line, $CurrentBlock);
+
+                if (isset($Block))
+                {
+                    $CurrentBlock = $Block;
+
+                    continue;
+                }
+                else
+                {
+                    if ($this->isBlockCompletable($CurrentBlock['type']))
+                    {
+                        $methodName = 'block' . $CurrentBlock['type'] . 'Complete';
+                        $CurrentBlock = $this->$methodName($CurrentBlock);
+                    }
+                }
+            }
+
+            # ~
+
+            $marker = $text[0];
+
+            # ~
+
+            $blockTypes = $this->unmarkedBlockTypes;
+
+            if (isset($this->BlockTypes[$marker]))
+            {
+                foreach ($this->BlockTypes[$marker] as $blockType)
+                {
+                    $blockTypes []= $blockType;
+                }
+            }
+
+            #
+            # ~
+
+            foreach ($blockTypes as $blockType)
+            {
+                $Block = $this->{"block$blockType"}($Line, $CurrentBlock);
+
+                if (isset($Block))
+                {
+                    $Block['type'] = $blockType;
+
+                    if ( ! isset($Block['identified']))
+                    {
+                        if (isset($CurrentBlock))
+                        {
+                            $Elements[] = $this->extractElement($CurrentBlock);
+                        }
+
+                        $Block['identified'] = true;
+                    }
+
+                    if ($this->isBlockContinuable($blockType))
+                    {
+                        $Block['continuable'] = true;
+                    }
+
+                    $CurrentBlock = $Block;
+
+                    continue 2;
+                }
+            }
+
+            # ~
+
+            if (isset($CurrentBlock) and $CurrentBlock['type'] === 'Paragraph')
+            {
+                $Block = $this->paragraphContinue($Line, $CurrentBlock);
+            }
+
+            if (isset($Block))
+            {
+                $CurrentBlock = $Block;
+            }
+            else
+            {
+                if (isset($CurrentBlock))
+                {
+                    $Elements[] = $this->extractElement($CurrentBlock);
+                }
+
+                $CurrentBlock = $this->paragraph($Line);
+
+                $CurrentBlock['identified'] = true;
+            }
+        }
+
+        # ~
+
+        if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type']))
+        {
+            $methodName = 'block' . $CurrentBlock['type'] . 'Complete';
+            $CurrentBlock = $this->$methodName($CurrentBlock);
+        }
+
+        # ~
+
+        if (isset($CurrentBlock))
+        {
+            $Elements[] = $this->extractElement($CurrentBlock);
+        }
+
+        # ~
+
+        return $Elements;
+    }
+
+    protected function extractElement(array $Component)
+    {
+        if ( ! isset($Component['element']))
+        {
+            if (isset($Component['markup']))
+            {
+                $Component['element'] = array('rawHtml' => $Component['markup']);
+            }
+            elseif (isset($Component['hidden']))
+            {
+                $Component['element'] = array();
+            }
+        }
+
+        return $Component['element'];
+    }
+
+    protected function isBlockContinuable($Type)
+    {
+        return method_exists($this, 'block' . $Type . 'Continue');
+    }
+
+    protected function isBlockCompletable($Type)
+    {
+        return method_exists($this, 'block' . $Type . 'Complete');
+    }
+
+    #
+    # Code
+
+    protected function blockCode($Line, $Block = null)
+    {
+        if (isset($Block) and $Block['type'] === 'Paragraph' and ! isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        if ($Line['indent'] >= 4)
+        {
+            $text = substr($Line['body'], 4);
+
+            $Block = array(
+                'element' => array(
+                    'name' => 'pre',
+                    'element' => array(
+                        'name' => 'code',
+                        'text' => $text,
+                    ),
+                ),
+            );
+
+            return $Block;
+        }
+    }
+
+    protected function blockCodeContinue($Line, $Block)
+    {
+        if ($Line['indent'] >= 4)
+        {
+            if (isset($Block['interrupted']))
+            {
+                $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']);
+
+                unset($Block['interrupted']);
+            }
+
+            $Block['element']['element']['text'] .= "\n";
+
+            $text = substr($Line['body'], 4);
+
+            $Block['element']['element']['text'] .= $text;
+
+            return $Block;
+        }
+    }
+
+    protected function blockCodeComplete($Block)
+    {
+        return $Block;
+    }
+
+    #
+    # Comment
+
+    protected function blockComment($Line)
+    {
+        if ($this->markupEscaped or $this->safeMode)
+        {
+            return;
+        }
+
+        if (strpos($Line['text'], '<!--') === 0)
+        {
+            $Block = array(
+                'element' => array(
+                    'rawHtml' => $Line['body'],
+                    'autobreak' => true,
+                ),
+            );
+
+            if (strpos($Line['text'], '-->') !== false)
+            {
+                $Block['closed'] = true;
+            }
+
+            return $Block;
+        }
+    }
+
+    protected function blockCommentContinue($Line, array $Block)
+    {
+        if (isset($Block['closed']))
+        {
+            return;
+        }
+
+        $Block['element']['rawHtml'] .= "\n" . $Line['body'];
+
+        if (strpos($Line['text'], '-->') !== false)
+        {
+            $Block['closed'] = true;
+        }
+
+        return $Block;
+    }
+
+    #
+    # Fenced Code
+
+    protected function blockFencedCode($Line)
+    {
+        $marker = $Line['text'][0];
+
+        $openerLength = strspn($Line['text'], $marker);
+
+        if ($openerLength < 3)
+        {
+            return;
+        }
+
+        $infostring = trim(substr($Line['text'], $openerLength), "\t ");
+
+        if (strpos($infostring, '`') !== false)
+        {
+            return;
+        }
+
+        $Element = array(
+            'name' => 'code',
+            'text' => '',
+        );
+
+        if ($infostring !== '')
+        {
+            /**
+             * https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#classes
+             * Every HTML element may have a class attribute specified.
+             * The attribute, if specified, must have a value that is a set
+             * of space-separated tokens representing the various classes
+             * that the element belongs to.
+             * [...]
+             * The space characters, for the purposes of this specification,
+             * are U+0020 SPACE, U+0009 CHARACTER TABULATION (tab),
+             * U+000A LINE FEED (LF), U+000C FORM FEED (FF), and
+             * U+000D CARRIAGE RETURN (CR).
+             */
+            $language = substr($infostring, 0, strcspn($infostring, " \t\n\f\r"));
+
+            $Element['attributes'] = array('class' => "language-$language");
+        }
+
+        $Block = array(
+            'char' => $marker,
+            'openerLength' => $openerLength,
+            'element' => array(
+                'name' => 'pre',
+                'element' => $Element,
+            ),
+        );
+
+        return $Block;
+    }
+
+    protected function blockFencedCodeContinue($Line, $Block)
+    {
+        if (isset($Block['complete']))
+        {
+            return;
+        }
+
+        if (isset($Block['interrupted']))
+        {
+            $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']);
+
+            unset($Block['interrupted']);
+        }
+
+        if (($len = strspn($Line['text'], $Block['char'])) >= $Block['openerLength']
+            and chop(substr($Line['text'], $len), ' ') === ''
+        ) {
+            $Block['element']['element']['text'] = substr($Block['element']['element']['text'], 1);
+
+            $Block['complete'] = true;
+
+            return $Block;
+        }
+
+        $Block['element']['element']['text'] .= "\n" . $Line['body'];
+
+        return $Block;
+    }
+
+    protected function blockFencedCodeComplete($Block)
+    {
+        return $Block;
+    }
+
+    #
+    # Header
+
+    protected function blockHeader($Line)
+    {
+        $level = strspn($Line['text'], '#');
+
+        if ($level > 6)
+        {
+            return;
+        }
+
+        $text = trim($Line['text'], '#');
+
+        if ($this->strictMode and isset($text[0]) and $text[0] !== ' ')
+        {
+            return;
+        }
+
+        $text = trim($text, ' ');
+
+        $Block = array(
+            'element' => array(
+                'name' => 'h' . $level,
+                'handler' => array(
+                    'function' => 'lineElements',
+                    'argument' => $text,
+                    'destination' => 'elements',
+                )
+            ),
+        );
+
+        return $Block;
+    }
+
+    #
+    # List
+
+    protected function blockList($Line, ?array $CurrentBlock = null)
+    {
+        list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]{1,9}+[.\)]');
+
+        if (preg_match('/^('.$pattern.'([ ]++|$))(.*+)/', $Line['text'], $matches))
+        {
+            $contentIndent = strlen($matches[2]);
+
+            if ($contentIndent >= 5)
+            {
+                $contentIndent -= 1;
+                $matches[1] = substr($matches[1], 0, -$contentIndent);
+                $matches[3] = str_repeat(' ', $contentIndent) . $matches[3];
+            }
+            elseif ($contentIndent === 0)
+            {
+                $matches[1] .= ' ';
+            }
+
+            $markerWithoutWhitespace = strstr($matches[1], ' ', true);
+
+            $Block = array(
+                'indent' => $Line['indent'],
+                'pattern' => $pattern,
+                'data' => array(
+                    'type' => $name,
+                    'marker' => $matches[1],
+                    'markerType' => ($name === 'ul' ? $markerWithoutWhitespace : substr($markerWithoutWhitespace, -1)),
+                ),
+                'element' => array(
+                    'name' => $name,
+                    'elements' => array(),
+                ),
+            );
+            $Block['data']['markerTypeRegex'] = preg_quote($Block['data']['markerType'], '/');
+
+            if ($name === 'ol')
+            {
+                $listStart = ltrim(strstr($matches[1], $Block['data']['markerType'], true), '0') ?: '0';
+
+                if ($listStart !== '1')
+                {
+                    if (
+                        isset($CurrentBlock)
+                        and $CurrentBlock['type'] === 'Paragraph'
+                        and ! isset($CurrentBlock['interrupted'])
+                    ) {
+                        return;
+                    }
+
+                    $Block['element']['attributes'] = array('start' => $listStart);
+                }
+            }
+
+            $Block['li'] = array(
+                'name' => 'li',
+                'handler' => array(
+                    'function' => 'li',
+                    'argument' => !empty($matches[3]) ? array($matches[3]) : array(),
+                    'destination' => 'elements'
+                )
+            );
+
+            $Block['element']['elements'] []= & $Block['li'];
+
+            return $Block;
+        }
+    }
+
+    protected function blockListContinue($Line, array $Block)
+    {
+        if (isset($Block['interrupted']) and empty($Block['li']['handler']['argument']))
+        {
+            return null;
+        }
+
+        $requiredIndent = ($Block['indent'] + strlen($Block['data']['marker']));
+
+        if ($Line['indent'] < $requiredIndent
+            and (
+                (
+                    $Block['data']['type'] === 'ol'
+                    and preg_match('/^[0-9]++'.$Block['data']['markerTypeRegex'].'(?:[ ]++(.*)|$)/', $Line['text'], $matches)
+                ) or (
+                    $Block['data']['type'] === 'ul'
+                    and preg_match('/^'.$Block['data']['markerTypeRegex'].'(?:[ ]++(.*)|$)/', $Line['text'], $matches)
+                )
+            )
+        ) {
+            if (isset($Block['interrupted']))
+            {
+                $Block['li']['handler']['argument'] []= '';
+
+                $Block['loose'] = true;
+
+                unset($Block['interrupted']);
+            }
+
+            unset($Block['li']);
+
+            $text = isset($matches[1]) ? $matches[1] : '';
+
+            $Block['indent'] = $Line['indent'];
+
+            $Block['li'] = array(
+                'name' => 'li',
+                'handler' => array(
+                    'function' => 'li',
+                    'argument' => array($text),
+                    'destination' => 'elements'
+                )
+            );
+
+            $Block['element']['elements'] []= & $Block['li'];
+
+            return $Block;
+        }
+        elseif ($Line['indent'] < $requiredIndent and $this->blockList($Line))
+        {
+            return null;
+        }
+
+        if ($Line['text'][0] === '[' and $this->blockReference($Line))
+        {
+            return $Block;
+        }
+
+        if ($Line['indent'] >= $requiredIndent)
+        {
+            if (isset($Block['interrupted']))
+            {
+                $Block['li']['handler']['argument'] []= '';
+
+                $Block['loose'] = true;
+
+                unset($Block['interrupted']);
+            }
+
+            $text = substr($Line['body'], $requiredIndent);
+
+            $Block['li']['handler']['argument'] []= $text;
+
+            return $Block;
+        }
+
+        if ( ! isset($Block['interrupted']))
+        {
+            $text = preg_replace('/^[ ]{0,'.$requiredIndent.'}+/', '', $Line['body']);
+
+            $Block['li']['handler']['argument'] []= $text;
+
+            return $Block;
+        }
+    }
+
+    protected function blockListComplete(array $Block)
+    {
+        if (isset($Block['loose']))
+        {
+            foreach ($Block['element']['elements'] as &$li)
+            {
+                if (end($li['handler']['argument']) !== '')
+                {
+                    $li['handler']['argument'] []= '';
+                }
+            }
+        }
+
+        return $Block;
+    }
+
+    #
+    # Quote
+
+    protected function blockQuote($Line)
+    {
+        if (preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches))
+        {
+            $Block = array(
+                'element' => array(
+                    'name' => 'blockquote',
+                    'handler' => array(
+                        'function' => 'linesElements',
+                        'argument' => (array) $matches[1],
+                        'destination' => 'elements',
+                    )
+                ),
+            );
+
+            return $Block;
+        }
+    }
+
+    protected function blockQuoteContinue($Line, array $Block)
+    {
+        if (isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        if ($Line['text'][0] === '>' and preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches))
+        {
+            $Block['element']['handler']['argument'] []= $matches[1];
+
+            return $Block;
+        }
+
+        if ( ! isset($Block['interrupted']))
+        {
+            $Block['element']['handler']['argument'] []= $Line['text'];
+
+            return $Block;
+        }
+    }
+
+    #
+    # Rule
+
+    protected function blockRule($Line)
+    {
+        $marker = $Line['text'][0];
+
+        if (substr_count($Line['text'], $marker) >= 3 and chop($Line['text'], " $marker") === '')
+        {
+            $Block = array(
+                'element' => array(
+                    'name' => 'hr',
+                ),
+            );
+
+            return $Block;
+        }
+    }
+
+    #
+    # Setext
+
+    protected function blockSetextHeader($Line, ?array $Block = null)
+    {
+        if ( ! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        if ($Line['indent'] < 4 and chop(chop($Line['text'], ' '), $Line['text'][0]) === '')
+        {
+            $Block['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2';
+
+            return $Block;
+        }
+    }
+
+    #
+    # Markup
+
+    protected function blockMarkup($Line)
+    {
+        if ($this->markupEscaped or $this->safeMode)
+        {
+            return;
+        }
+
+        if (preg_match('/^<[\/]?+(\w*)(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+(\/)?>/', $Line['text'], $matches))
+        {
+            $element = strtolower($matches[1]);
+
+            if (in_array($element, $this->textLevelElements))
+            {
+                return;
+            }
+
+            $Block = array(
+                'name' => $matches[1],
+                'element' => array(
+                    'rawHtml' => $Line['text'],
+                    'autobreak' => true,
+                ),
+            );
+
+            return $Block;
+        }
+    }
+
+    protected function blockMarkupContinue($Line, array $Block)
+    {
+        if (isset($Block['closed']) or isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        $Block['element']['rawHtml'] .= "\n" . $Line['body'];
+
+        return $Block;
+    }
+
+    #
+    # Reference
+
+    protected function blockReference($Line)
+    {
+        if (strpos($Line['text'], ']') !== false
+            and preg_match('/^\[(.+?)\]:[ ]*+<?(\S+?)>?(?:[ ]+["\'(](.+)["\')])?[ ]*+$/', $Line['text'], $matches)
+        ) {
+            $id = strtolower($matches[1]);
+
+            $Data = array(
+                'url' => $matches[2],
+                'title' => isset($matches[3]) ? $matches[3] : null,
+            );
+
+            $this->DefinitionData['Reference'][$id] = $Data;
+
+            $Block = array(
+                'element' => array(),
+            );
+
+            return $Block;
+        }
+    }
+
+    #
+    # Table
+
+    protected function blockTable($Line, ?array $Block = null)
+    {
+        if ( ! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        if (
+            strpos($Block['element']['handler']['argument'], '|') === false
+            and strpos($Line['text'], '|') === false
+            and strpos($Line['text'], ':') === false
+            or strpos($Block['element']['handler']['argument'], "\n") !== false
+        ) {
+            return;
+        }
+
+        if (chop($Line['text'], ' -:|') !== '')
+        {
+            return;
+        }
+
+        $alignments = array();
+
+        $divider = $Line['text'];
+
+        $divider = trim($divider);
+        $divider = trim($divider, '|');
+
+        $dividerCells = explode('|', $divider);
+
+        foreach ($dividerCells as $dividerCell)
+        {
+            $dividerCell = trim($dividerCell);
+
+            if ($dividerCell === '')
+            {
+                return;
+            }
+
+            $alignment = null;
+
+            if ($dividerCell[0] === ':')
+            {
+                $alignment = 'left';
+            }
+
+            if (substr($dividerCell, - 1) === ':')
+            {
+                $alignment = $alignment === 'left' ? 'center' : 'right';
+            }
+
+            $alignments []= $alignment;
+        }
+
+        # ~
+
+        $HeaderElements = array();
+
+        $header = $Block['element']['handler']['argument'];
+
+        $header = trim($header);
+        $header = trim($header, '|');
+
+        $headerCells = explode('|', $header);
+
+        if (count($headerCells) !== count($alignments))
+        {
+            return;
+        }
+
+        foreach ($headerCells as $index => $headerCell)
+        {
+            $headerCell = trim($headerCell);
+
+            $HeaderElement = array(
+                'name' => 'th',
+                'handler' => array(
+                    'function' => 'lineElements',
+                    'argument' => $headerCell,
+                    'destination' => 'elements',
+                )
+            );
+
+            if (isset($alignments[$index]))
+            {
+                $alignment = $alignments[$index];
+
+                $HeaderElement['attributes'] = array(
+                    'style' => "text-align: $alignment;",
+                );
+            }
+
+            $HeaderElements []= $HeaderElement;
+        }
+
+        # ~
+
+        $Block = array(
+            'alignments' => $alignments,
+            'identified' => true,
+            'element' => array(
+                'name' => 'table',
+                'elements' => array(),
+            ),
+        );
+
+        $Block['element']['elements'] []= array(
+            'name' => 'thead',
+        );
+
+        $Block['element']['elements'] []= array(
+            'name' => 'tbody',
+            'elements' => array(),
+        );
+
+        $Block['element']['elements'][0]['elements'] []= array(
+            'name' => 'tr',
+            'elements' => $HeaderElements,
+        );
+
+        return $Block;
+    }
+
+    protected function blockTableContinue($Line, array $Block)
+    {
+        if (isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        if (count($Block['alignments']) === 1 or $Line['text'][0] === '|' or strpos($Line['text'], '|'))
+        {
+            $Elements = array();
+
+            $row = $Line['text'];
+
+            $row = trim($row);
+            $row = trim($row, '|');
+
+            preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]++`|`)++/', $row, $matches);
+
+            $cells = array_slice($matches[0], 0, count($Block['alignments']));
+
+            foreach ($cells as $index => $cell)
+            {
+                $cell = trim($cell);
+
+                $Element = array(
+                    'name' => 'td',
+                    'handler' => array(
+                        'function' => 'lineElements',
+                        'argument' => $cell,
+                        'destination' => 'elements',
+                    )
+                );
+
+                if (isset($Block['alignments'][$index]))
+                {
+                    $Element['attributes'] = array(
+                        'style' => 'text-align: ' . $Block['alignments'][$index] . ';',
+                    );
+                }
+
+                $Elements []= $Element;
+            }
+
+            $Element = array(
+                'name' => 'tr',
+                'elements' => $Elements,
+            );
+
+            $Block['element']['elements'][1]['elements'] []= $Element;
+
+            return $Block;
+        }
+    }
+
+    #
+    # ~
+    #
+
+    protected function paragraph($Line)
+    {
+        return array(
+            'type' => 'Paragraph',
+            'element' => array(
+                'name' => 'p',
+                'handler' => array(
+                    'function' => 'lineElements',
+                    'argument' => $Line['text'],
+                    'destination' => 'elements',
+                ),
+            ),
+        );
+    }
+
+    protected function paragraphContinue($Line, array $Block)
+    {
+        if (isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        $Block['element']['handler']['argument'] .= "\n".$Line['text'];
+
+        return $Block;
+    }
+
+    #
+    # Inline Elements
+    #
+
+    protected $InlineTypes = array(
+        '!' => array('Image'),
+        '&' => array('SpecialCharacter'),
+        '*' => array('Emphasis'),
+        ':' => array('Url'),
+        '<' => array('UrlTag', 'EmailTag', 'Markup'),
+        '[' => array('Link'),
+        '_' => array('Emphasis'),
+        '`' => array('Code'),
+        '~' => array('Strikethrough'),
+        '\\' => array('EscapeSequence'),
+    );
+
+    # ~
+
+    protected $inlineMarkerList = '!*_&[:<`~\\';
+
+    #
+    # ~
+    #
+
+    public function line($text, $nonNestables = array())
+    {
+        return $this->elements($this->lineElements($text, $nonNestables));
+    }
+
+    protected function lineElements($text, $nonNestables = array())
+    {
+        # standardize line breaks
+        $text = str_replace(array("\r\n", "\r"), "\n", $text);
+
+        $Elements = array();
+
+        $nonNestables = (empty($nonNestables)
+            ? array()
+            : array_combine($nonNestables, $nonNestables)
+        );
+
+        # $excerpt is based on the first occurrence of a marker
+
+        while ($excerpt = strpbrk($text, $this->inlineMarkerList))
+        {
+            $marker = $excerpt[0];
+
+            $markerPosition = strlen($text) - strlen($excerpt);
+
+            $Excerpt = array('text' => $excerpt, 'context' => $text);
+
+            foreach ($this->InlineTypes[$marker] as $inlineType)
+            {
+                # check to see if the current inline type is nestable in the current context
+
+                if (isset($nonNestables[$inlineType]))
+                {
+                    continue;
+                }
+
+                $Inline = $this->{"inline$inlineType"}($Excerpt);
+
+                if ( ! isset($Inline))
+                {
+                    continue;
+                }
+
+                # makes sure that the inline belongs to "our" marker
+
+                if (isset($Inline['position']) and $Inline['position'] > $markerPosition)
+                {
+                    continue;
+                }
+
+                # sets a default inline position
+
+                if ( ! isset($Inline['position']))
+                {
+                    $Inline['position'] = $markerPosition;
+                }
+
+                # cause the new element to 'inherit' our non nestables
+
+
+                $Inline['element']['nonNestables'] = isset($Inline['element']['nonNestables'])
+                    ? array_merge($Inline['element']['nonNestables'], $nonNestables)
+                    : $nonNestables
+                ;
+
+                # the text that comes before the inline
+                $unmarkedText = substr($text, 0, $Inline['position']);
+
+                # compile the unmarked text
+                $InlineText = $this->inlineText($unmarkedText);
+                $Elements[] = $InlineText['element'];
+
+                # compile the inline
+                $Elements[] = $this->extractElement($Inline);
+
+                # remove the examined text
+                $text = substr($text, $Inline['position'] + $Inline['extent']);
+
+                continue 2;
+            }
+
+            # the marker does not belong to an inline
+
+            $unmarkedText = substr($text, 0, $markerPosition + 1);
+
+            $InlineText = $this->inlineText($unmarkedText);
+            $Elements[] = $InlineText['element'];
+
+            $text = substr($text, $markerPosition + 1);
+        }
+
+        $InlineText = $this->inlineText($text);
+        $Elements[] = $InlineText['element'];
+
+        foreach ($Elements as &$Element)
+        {
+            if ( ! isset($Element['autobreak']))
+            {
+                $Element['autobreak'] = false;
+            }
+        }
+
+        return $Elements;
+    }
+
+    #
+    # ~
+    #
+
+    protected function inlineText($text)
+    {
+        $Inline = array(
+            'extent' => strlen($text),
+            'element' => array(),
+        );
+
+        $Inline['element']['elements'] = self::pregReplaceElements(
+            $this->breaksEnabled ? '/[ ]*+\n/' : '/(?:[ ]*+\\\\|[ ]{2,}+)\n/',
+            array(
+                array('name' => 'br'),
+                array('text' => "\n"),
+            ),
+            $text
+        );
+
+        return $Inline;
+    }
+
+    protected function inlineCode($Excerpt)
+    {
+        $marker = $Excerpt['text'][0];
+
+        if (preg_match('/^(['.$marker.']++)[ ]*+(.+?)[ ]*+(?<!['.$marker.'])\1(?!'.$marker.')/s', $Excerpt['text'], $matches))
+        {
+            $text = $matches[2];
+            $text = preg_replace('/[ ]*+\n/', ' ', $text);
+
+            return array(
+                'extent' => strlen($matches[0]),
+                'element' => array(
+                    'name' => 'code',
+                    'text' => $text,
+                ),
+            );
+        }
+    }
+
+    protected function inlineEmailTag($Excerpt)
+    {
+        $hostnameLabel = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?';
+
+        $commonMarkEmail = '[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]++@'
+            . $hostnameLabel . '(?:\.' . $hostnameLabel . ')*';
+
+        if (strpos($Excerpt['text'], '>') !== false
+            and preg_match("/^<((mailto:)?$commonMarkEmail)>/i", $Excerpt['text'], $matches)
+        ){
+            $url = $matches[1];
+
+            if ( ! isset($matches[2]))
+            {
+                $url = "mailto:$url";
+            }
+
+            return array(
+                'extent' => strlen($matches[0]),
+                'element' => array(
+                    'name' => 'a',
+                    'text' => $matches[1],
+                    'attributes' => array(
+                        'href' => $url,
+                    ),
+                ),
+            );
+        }
+    }
+
+    protected function inlineEmphasis($Excerpt)
+    {
+        if ( ! isset($Excerpt['text'][1]))
+        {
+            return;
+        }
+
+        $marker = $Excerpt['text'][0];
+
+        if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches))
+        {
+            $emphasis = 'strong';
+        }
+        elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches))
+        {
+            $emphasis = 'em';
+        }
+        else
+        {
+            return;
+        }
+
+        return array(
+            'extent' => strlen($matches[0]),
+            'element' => array(
+                'name' => $emphasis,
+                'handler' => array(
+                    'function' => 'lineElements',
+                    'argument' => $matches[1],
+                    'destination' => 'elements',
+                )
+            ),
+        );
+    }
+
+    protected function inlineEscapeSequence($Excerpt)
+    {
+        if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters))
+        {
+            return array(
+                'element' => array('rawHtml' => $Excerpt['text'][1]),
+                'extent' => 2,
+            );
+        }
+    }
+
+    protected function inlineImage($Excerpt)
+    {
+        if ( ! isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[')
+        {
+            return;
+        }
+
+        $Excerpt['text']= substr($Excerpt['text'], 1);
+
+        $Link = $this->inlineLink($Excerpt);
+
+        if ($Link === null)
+        {
+            return;
+        }
+
+        $Inline = array(
+            'extent' => $Link['extent'] + 1,
+            'element' => array(
+                'name' => 'img',
+                'attributes' => array(
+                    'src' => $Link['element']['attributes']['href'],
+                    'alt' => $Link['element']['handler']['argument'],
+                ),
+                'autobreak' => true,
+            ),
+        );
+
+        $Inline['element']['attributes'] += $Link['element']['attributes'];
+
+        unset($Inline['element']['attributes']['href']);
+
+        return $Inline;
+    }
+
+    protected function inlineLink($Excerpt)
+    {
+        $Element = array(
+            'name' => 'a',
+            'handler' => array(
+                'function' => 'lineElements',
+                'argument' => null,
+                'destination' => 'elements',
+            ),
+            'nonNestables' => array('Url', 'Link'),
+            'attributes' => array(
+                'href' => null,
+                'title' => null,
+            ),
+        );
+
+        $extent = 0;
+
+        $remainder = $Excerpt['text'];
+
+        if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches))
+        {
+            $Element['handler']['argument'] = $matches[1];
+
+            $extent += strlen($matches[0]);
+
+            $remainder = substr($remainder, $extent);
+        }
+        else
+        {
+            return;
+        }
+
+        if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*+"|\'[^\']*+\'))?\s*+[)]/', $remainder, $matches))
+        {
+            $Element['attributes']['href'] = $matches[1];
+
+            if (isset($matches[2]))
+            {
+                $Element['attributes']['title'] = substr($matches[2], 1, - 1);
+            }
+
+            $extent += strlen($matches[0]);
+        }
+        else
+        {
+            if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches))
+            {
+                $definition = strlen($matches[1]) ? $matches[1] : $Element['handler']['argument'];
+                $definition = strtolower($definition);
+
+                $extent += strlen($matches[0]);
+            }
+            else
+            {
+                $definition = strtolower($Element['handler']['argument']);
+            }
+
+            if ( ! isset($this->DefinitionData['Reference'][$definition]))
+            {
+                return;
+            }
+
+            $Definition = $this->DefinitionData['Reference'][$definition];
+
+            $Element['attributes']['href'] = $Definition['url'];
+            $Element['attributes']['title'] = $Definition['title'];
+        }
+
+        return array(
+            'extent' => $extent,
+            'element' => $Element,
+        );
+    }
+
+    protected function inlineMarkup($Excerpt)
+    {
+        if ($this->markupEscaped or $this->safeMode or strpos($Excerpt['text'], '>') === false)
+        {
+            return;
+        }
+
+        if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w[\w-]*+[ ]*+>/s', $Excerpt['text'], $matches))
+        {
+            return array(
+                'element' => array('rawHtml' => $matches[0]),
+                'extent' => strlen($matches[0]),
+            );
+        }
+
+        if ($Excerpt['text'][1] === '!' and preg_match('/^<!---?[^>-](?:-?+[^-])*-->/s', $Excerpt['text'], $matches))
+        {
+            return array(
+                'element' => array('rawHtml' => $matches[0]),
+                'extent' => strlen($matches[0]),
+            );
+        }
+
+        if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w[\w-]*+(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+\/?>/s', $Excerpt['text'], $matches))
+        {
+            return array(
+                'element' => array('rawHtml' => $matches[0]),
+                'extent' => strlen($matches[0]),
+            );
+        }
+    }
+
+    protected function inlineSpecialCharacter($Excerpt)
+    {
+        if (substr($Excerpt['text'], 1, 1) !== ' ' and strpos($Excerpt['text'], ';') !== false
+            and preg_match('/^&(#?+[0-9a-zA-Z]++);/', $Excerpt['text'], $matches)
+        ) {
+            return array(
+                'element' => array('rawHtml' => '&' . $matches[1] . ';'),
+                'extent' => strlen($matches[0]),
+            );
+        }
+
+        return;
+    }
+
+    protected function inlineStrikethrough($Excerpt)
+    {
+        if ( ! isset($Excerpt['text'][1]))
+        {
+            return;
+        }
+
+        if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches))
+        {
+            return array(
+                'extent' => strlen($matches[0]),
+                'element' => array(
+                    'name' => 'del',
+                    'handler' => array(
+                        'function' => 'lineElements',
+                        'argument' => $matches[1],
+                        'destination' => 'elements',
+                    )
+                ),
+            );
+        }
+    }
+
+    protected function inlineUrl($Excerpt)
+    {
+        if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/')
+        {
+            return;
+        }
+
+        if (strpos($Excerpt['context'], 'http') !== false
+            and preg_match('/\bhttps?+:[\/]{2}[^\s<]+\b\/*+/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE)
+        ) {
+            $url = $matches[0][0];
+
+            $Inline = array(
+                'extent' => strlen($matches[0][0]),
+                'position' => $matches[0][1],
+                'element' => array(
+                    'name' => 'a',
+                    'text' => $url,
+                    'attributes' => array(
+                        'href' => $url,
+                    ),
+                ),
+            );
+
+            return $Inline;
+        }
+    }
+
+    protected function inlineUrlTag($Excerpt)
+    {
+        if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w++:\/{2}[^ >]++)>/i', $Excerpt['text'], $matches))
+        {
+            $url = $matches[1];
+
+            return array(
+                'extent' => strlen($matches[0]),
+                'element' => array(
+                    'name' => 'a',
+                    'text' => $url,
+                    'attributes' => array(
+                        'href' => $url,
+                    ),
+                ),
+            );
+        }
+    }
+
+    # ~
+
+    protected function unmarkedText($text)
+    {
+        $Inline = $this->inlineText($text);
+        return $this->element($Inline['element']);
+    }
+
+    #
+    # Handlers
+    #
+
+    protected function handle(array $Element)
+    {
+        if (isset($Element['handler']))
+        {
+            if (!isset($Element['nonNestables']))
+            {
+                $Element['nonNestables'] = array();
+            }
+
+            if (is_string($Element['handler']))
+            {
+                $function = $Element['handler'];
+                $argument = $Element['text'];
+                unset($Element['text']);
+                $destination = 'rawHtml';
+            }
+            else
+            {
+                $function = $Element['handler']['function'];
+                $argument = $Element['handler']['argument'];
+                $destination = $Element['handler']['destination'];
+            }
+
+            $Element[$destination] = $this->{$function}($argument, $Element['nonNestables']);
+
+            if ($destination === 'handler')
+            {
+                $Element = $this->handle($Element);
+            }
+
+            unset($Element['handler']);
+        }
+
+        return $Element;
+    }
+
+    protected function handleElementRecursive(array $Element)
+    {
+        return $this->elementApplyRecursive(array($this, 'handle'), $Element);
+    }
+
+    protected function handleElementsRecursive(array $Elements)
+    {
+        return $this->elementsApplyRecursive(array($this, 'handle'), $Elements);
+    }
+
+    protected function elementApplyRecursive($closure, array $Element)
+    {
+        $Element = call_user_func($closure, $Element);
+
+        if (isset($Element['elements']))
+        {
+            $Element['elements'] = $this->elementsApplyRecursive($closure, $Element['elements']);
+        }
+        elseif (isset($Element['element']))
+        {
+            $Element['element'] = $this->elementApplyRecursive($closure, $Element['element']);
+        }
+
+        return $Element;
+    }
+
+    protected function elementApplyRecursiveDepthFirst($closure, array $Element)
+    {
+        if (isset($Element['elements']))
+        {
+            $Element['elements'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['elements']);
+        }
+        elseif (isset($Element['element']))
+        {
+            $Element['element'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['element']);
+        }
+
+        $Element = call_user_func($closure, $Element);
+
+        return $Element;
+    }
+
+    protected function elementsApplyRecursive($closure, array $Elements)
+    {
+        foreach ($Elements as &$Element)
+        {
+            $Element = $this->elementApplyRecursive($closure, $Element);
+        }
+
+        return $Elements;
+    }
+
+    protected function elementsApplyRecursiveDepthFirst($closure, array $Elements)
+    {
+        foreach ($Elements as &$Element)
+        {
+            $Element = $this->elementApplyRecursiveDepthFirst($closure, $Element);
+        }
+
+        return $Elements;
+    }
+
+    protected function element(array $Element)
+    {
+        if ($this->safeMode)
+        {
+            $Element = $this->sanitiseElement($Element);
+        }
+
+        # identity map if element has no handler
+        $Element = $this->handle($Element);
+
+        $hasName = isset($Element['name']);
+
+        $markup = '';
+
+        if ($hasName)
+        {
+            $markup .= '<' . $Element['name'];
+
+            if (isset($Element['attributes']))
+            {
+                foreach ($Element['attributes'] as $name => $value)
+                {
+                    if ($value === null)
+                    {
+                        continue;
+                    }
+
+                    $markup .= " $name=\"".self::escape($value).'"';
+                }
+            }
+        }
+
+        $permitRawHtml = false;
+
+        if (isset($Element['text']))
+        {
+            $text = $Element['text'];
+        }
+        // very strongly consider an alternative if you're writing an
+        // extension
+        elseif (isset($Element['rawHtml']))
+        {
+            $text = $Element['rawHtml'];
+
+            $allowRawHtmlInSafeMode = isset($Element['allowRawHtmlInSafeMode']) && $Element['allowRawHtmlInSafeMode'];
+            $permitRawHtml = !$this->safeMode || $allowRawHtmlInSafeMode;
+        }
+
+        $hasContent = isset($text) || isset($Element['element']) || isset($Element['elements']);
+
+        if ($hasContent)
+        {
+            $markup .= $hasName ? '>' : '';
+
+            if (isset($Element['elements']))
+            {
+                $markup .= $this->elements($Element['elements']);
+            }
+            elseif (isset($Element['element']))
+            {
+                $markup .= $this->element($Element['element']);
+            }
+            else
+            {
+                if (!$permitRawHtml)
+                {
+                    $markup .= self::escape($text, true);
+                }
+                else
+                {
+                    $markup .= $text;
+                }
+            }
+
+            $markup .= $hasName ? '</' . $Element['name'] . '>' : '';
+        }
+        elseif ($hasName)
+        {
+            $markup .= ' />';
+        }
+
+        return $markup;
+    }
+
+    protected function elements(array $Elements)
+    {
+        $markup = '';
+
+        $autoBreak = true;
+
+        foreach ($Elements as $Element)
+        {
+            if (empty($Element))
+            {
+                continue;
+            }
+
+            $autoBreakNext = (isset($Element['autobreak'])
+                ? $Element['autobreak'] : isset($Element['name'])
+            );
+            // (autobreak === false) covers both sides of an element
+            $autoBreak = !$autoBreak ? $autoBreak : $autoBreakNext;
+
+            $markup .= ($autoBreak ? "\n" : '') . $this->element($Element);
+            $autoBreak = $autoBreakNext;
+        }
+
+        $markup .= $autoBreak ? "\n" : '';
+
+        return $markup;
+    }
+
+    # ~
+
+    protected function li($lines)
+    {
+        $Elements = $this->linesElements($lines);
+
+        if ( ! in_array('', $lines)
+            and isset($Elements[0]) and isset($Elements[0]['name'])
+            and $Elements[0]['name'] === 'p'
+        ) {
+            unset($Elements[0]['name']);
+        }
+
+        return $Elements;
+    }
+
+    #
+    # AST Convenience
+    #
+
+    /**
+     * Replace occurrences $regexp with $Elements in $text. Return an array of
+     * elements representing the replacement.
+     */
+    protected static function pregReplaceElements($regexp, $Elements, $text)
+    {
+        $newElements = array();
+
+        while (preg_match($regexp, $text, $matches, PREG_OFFSET_CAPTURE))
+        {
+            $offset = $matches[0][1];
+            $before = substr($text, 0, $offset);
+            $after = substr($text, $offset + strlen($matches[0][0]));
+
+            $newElements[] = array('text' => $before);
+
+            foreach ($Elements as $Element)
+            {
+                $newElements[] = $Element;
+            }
+
+            $text = $after;
+        }
+
+        $newElements[] = array('text' => $text);
+
+        return $newElements;
+    }
+
+    #
+    # Deprecated Methods
+    #
+
+    function parse($text)
+    {
+        $markup = $this->text($text);
+
+        return $markup;
+    }
+
+    protected function sanitiseElement(array $Element)
+    {
+        static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/';
+        static $safeUrlNameToAtt  = array(
+            'a'   => 'href',
+            'img' => 'src',
+        );
+
+        if ( ! isset($Element['name']))
+        {
+            unset($Element['attributes']);
+            return $Element;
+        }
+
+        if (isset($safeUrlNameToAtt[$Element['name']]))
+        {
+            $Element = $this->filterUnsafeUrlInAttribute($Element, $safeUrlNameToAtt[$Element['name']]);
+        }
+
+        if ( ! empty($Element['attributes']))
+        {
+            foreach ($Element['attributes'] as $att => $val)
+            {
+                # filter out badly parsed attribute
+                if ( ! preg_match($goodAttribute, $att))
+                {
+                    unset($Element['attributes'][$att]);
+                }
+                # dump onevent attribute
+                elseif (self::striAtStart($att, 'on'))
+                {
+                    unset($Element['attributes'][$att]);
+                }
+            }
+        }
+
+        return $Element;
+    }
+
+    protected function filterUnsafeUrlInAttribute(array $Element, $attribute)
+    {
+        foreach ($this->safeLinksWhitelist as $scheme)
+        {
+            if (self::striAtStart($Element['attributes'][$attribute], $scheme))
+            {
+                return $Element;
+            }
+        }
+
+        $Element['attributes'][$attribute] = str_replace(':', '%3A', $Element['attributes'][$attribute]);
+
+        return $Element;
+    }
+
+    #
+    # Static Methods
+    #
+
+    protected static function escape($text, $allowQuotes = false)
+    {
+        return htmlspecialchars($text, $allowQuotes ? ENT_NOQUOTES : ENT_QUOTES, 'UTF-8');
+    }
+
+    protected static function striAtStart($string, $needle)
+    {
+        $len = strlen($needle);
+
+        if ($len > strlen($string))
+        {
+            return false;
+        }
+        else
+        {
+            return strtolower(substr($string, 0, $len)) === strtolower($needle);
+        }
+    }
+
+    static function instance($name = 'default')
+    {
+        if (isset(self::$instances[$name]))
+        {
+            return self::$instances[$name];
+        }
+
+        $instance = new static();
+
+        self::$instances[$name] = $instance;
+
+        return $instance;
+    }
+
+    private static $instances = array();
+
+    #
+    # Fields
+    #
+
+    protected $DefinitionData;
+
+    #
+    # Read-Only
+
+    protected $specialCharacters = array(
+        '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|', '~'
+    );
+
+    protected $StrongRegex = array(
+        '*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*+[*])+?)[*]{2}(?![*])/s',
+        '_' => '/^__((?:\\\\_|[^_]|_[^_]*+_)+?)__(?!_)/us',
+    );
+
+    protected $EmRegex = array(
+        '*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s',
+        '_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us',
+    );
+
+    protected $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*+(?:\s*+=\s*+(?:[^"\'=<>`\s]+|"[^"]*+"|\'[^\']*+\'))?+';
+
+    protected $voidElements = array(
+        'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source',
+    );
+
+    protected $textLevelElements = array(
+        'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont',
+        'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing',
+        'i', 'rp', 'del', 'code',          'strike', 'marquee',
+        'q', 'rt', 'ins', 'font',          'strong',
+        's', 'tt', 'kbd', 'mark',
+        'u', 'xm', 'sub', 'nobr',
+                   'sup', 'ruby',
+                   'var', 'span',
+                   'wbr', 'time',
+    );
+}
diff --git a/lib/db.php b/lib/db.php
new file mode 100644
index 0000000..cd7371d
--- /dev/null
+++ b/lib/db.php
@@ -0,0 +1,156 @@
+<?php
+
+class Db {
+    private static ?PDO $pdo = null;
+
+    /**
+     * this function opens the sqlite connection that the rest of the app shares
+     * as the global $dbh, with foreign keys + WAL on. subsequent calls return
+     * the same handle
+     *
+     * @return PDO
+     */
+    public static function handle() : PDO {
+        if (self::$pdo === null) {
+            $path = $_SERVER['FORUM_DB'] ?? getenv('FORUM_DB') ?: (APP_ROOT . '/data/forum.db');
+            $dir = dirname($path);
+            if (!is_dir($dir)) {
+                mkdir($dir, 0775, true);
+            }
+            self::$pdo = new PDO('sqlite:' . $path);
+            self::$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+            self::$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
+            self::$pdo->exec('PRAGMA journal_mode = WAL');
+            self::$pdo->exec('PRAGMA foreign_keys = ON');
+        }
+        return self::$pdo;
+    }
+
+    /**
+     * this function brings the schema up to date: it creates the tables and
+     * indexes if theyre missing, patches in columns added later, and builds
+     * the search index
+     *
+     * @return void
+     */
+    public static function migrate() : void {
+        $dbh = self::handle();
+
+        $schema = <<<SQL
+            CREATE TABLE IF NOT EXISTS users (
+                id            INTEGER PRIMARY KEY AUTOINCREMENT,
+                username      TEXT NOT NULL UNIQUE,
+                password_hash TEXT NOT NULL,
+                role          TEXT NOT NULL DEFAULT 'normal',
+                created_at    INTEGER NOT NULL
+            );
+
+            CREATE TABLE IF NOT EXISTS boards (
+                id          INTEGER PRIMARY KEY AUTOINCREMENT,
+                slug        TEXT NOT NULL UNIQUE,
+                name        TEXT NOT NULL,
+                description TEXT NOT NULL DEFAULT '',
+                category    TEXT NOT NULL DEFAULT 'General',
+                position    INTEGER NOT NULL DEFAULT 0
+            );
+
+            CREATE TABLE IF NOT EXISTS threads (
+                id         INTEGER PRIMARY KEY AUTOINCREMENT,
+                board_id   INTEGER REFERENCES boards(id),
+                user_id    INTEGER NOT NULL REFERENCES users(id),
+                title      TEXT NOT NULL,
+                pinned     INTEGER NOT NULL DEFAULT 0,
+                locked     INTEGER NOT NULL DEFAULT 0,
+                created_at INTEGER NOT NULL,
+                updated_at INTEGER NOT NULL
+            );
+
+            CREATE TABLE IF NOT EXISTS posts (
+                id         INTEGER PRIMARY KEY AUTOINCREMENT,
+                thread_id  INTEGER NOT NULL REFERENCES threads(id),
+                user_id    INTEGER NOT NULL REFERENCES users(id),
+                body       TEXT NOT NULL,
+                created_at INTEGER NOT NULL
+            );
+
+            CREATE INDEX IF NOT EXISTS idx_threads_board ON threads(board_id, pinned DESC, updated_at DESC);
+            CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, created_at);
+        SQL;
+        $dbh->exec($schema);
+
+        self::ensure_column('users', 'role', "TEXT NOT NULL DEFAULT 'normal'");
+        self::ensure_column('threads', 'board_id', "INTEGER");
+        self::ensure_column('threads', 'pinned', "INTEGER NOT NULL DEFAULT 0");
+        self::ensure_column('threads', 'locked', "INTEGER NOT NULL DEFAULT 0");
+
+        self::build_search();
+    }
+
+    /**
+     * this function adds a column to a table only if its not there yet, so an
+     * older database picks up new columns in place without a full rebuild
+     *
+     * @param string $table
+     * @param string $column
+     * @param string $definition
+     * @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");
+        }
+    }
+
+    /**
+     * 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
+     * inserted/deleted, and backfills the index from existing posts the first
+     * time it runs against a db that already has content
+     *
+     * @return void
+     */
+    public static function build_search() : void {
+        $dbh = self::handle();
+
+        $dbh->exec(<<<SQL
+            CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(
+                title, body, thread_id UNINDEXED, board_slug UNINDEXED,
+                tokenize = 'porter unicode61'
+            )
+        SQL);
+
+        $dbh->exec(<<<SQL
+            CREATE TRIGGER IF NOT EXISTS posts_search_ai AFTER INSERT ON posts BEGIN
+                INSERT INTO search_fts (rowid, title, body, thread_id, board_slug)
+                VALUES (
+                    new.id,
+                    (SELECT title FROM threads WHERE id = new.thread_id),
+                    new.body,
+                    new.thread_id,
+                    (SELECT b.slug FROM threads t LEFT JOIN boards b ON b.id = t.board_id WHERE t.id = new.thread_id)
+                );
+            END
+        SQL);
+
+        $dbh->exec(<<<SQL
+            CREATE TRIGGER IF NOT EXISTS posts_search_ad AFTER DELETE ON posts BEGIN
+                DELETE FROM search_fts WHERE rowid = old.id;
+            END
+        SQL);
+
+        $indexed = (int) $dbh->query("SELECT COUNT(*) FROM search_fts")->fetchColumn();
+        $posts = (int) $dbh->query("SELECT COUNT(*) FROM posts")->fetchColumn();
+        if ($indexed === 0 && $posts > 0) {
+            $dbh->exec(<<<SQL
+                INSERT INTO search_fts (rowid, title, body, thread_id, board_slug)
+                SELECT p.id, t.title, p.body, p.thread_id, b.slug
+                FROM posts p
+                JOIN threads t ON t.id = p.thread_id
+                LEFT JOIN boards b ON b.id = t.board_id
+            SQL);
+        }
+    }
+}
diff --git a/lib/helpers.php b/lib/helpers.php
new file mode 100644
index 0000000..c226d33
--- /dev/null
+++ b/lib/helpers.php
@@ -0,0 +1,51 @@
+<?php
+
+function esc(?string $s) : string {
+    return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
+}
+
+function redirect(string $path) : void {
+    header('Location: ' . $path);
+    exit;
+}
+
+/**
+ * this function coerces a user row's role string into the Role enum, falling
+ * back to normal for anything missing or unknown
+ *
+ * @param array|null $user
+ * @return Role
+ */
+function role_of(?array $user) : Role {
+    if ($user === null) {
+        return Role::Normal;
+    }
+    return Role::tryFrom($user['role'] ?? 'normal') ?? Role::Normal;
+}
+
+/**
+ * this function returns the role badge span for a role string (view helper)
+ *
+ * @param string|null $role
+ * @return string
+ */
+function role_badge(?string $role) : string {
+    return ($role === null ? Role::Normal : (Role::tryFrom($role) ?? Role::Normal))->badge();
+}
+
+/**
+ * this function turns a unix timestamp into a short relative string like
+ * "5m ago" or "3d ago", and once its older than about a month it just shows
+ * the date
+ *
+ * @param int $ts
+ * @return string
+ */
+function time_ago(int $ts) : string {
+    $diff = time() - $ts;
+    if ($diff < 60)      return 'just now';
+    if ($diff < 3600)    return (int) floor($diff / 60) . 'm ago';
+    if ($diff < 86400)   return (int) floor($diff / 3600) . 'h ago';
+    if ($diff < 2592000) return (int) floor($diff / 86400) . 'd ago';
+    return date('Y-m-d', $ts);
+}
diff --git a/lib/markup.php b/lib/markup.php
new file mode 100644
index 0000000..caa9f8c
--- /dev/null
+++ b/lib/markup.php
@@ -0,0 +1,91 @@
+<?php
+
+class Markup {
+    const TAGS = ['b', 'strong', 'i', 'em', 'u', 's', 'code'];
+
+    const HIGHLIGHT_LANGS = [
+        'c' => 'c', 'h' => 'c',
+        'zig' => 'zig',
+        'php' => 'php',
+        'sh' => 'bash', 'bash' => 'bash',
+        'nix' => 'nix',
+        'lua' => 'lua',
+    ];
+
+    /**
+     * this function renders a raw post body into safe html. fenced code blocks
+     * get pulled out first and run through the syntax highlighter, the rest
+     * goes through parsedown in safe mode (so any raw html is escaped), then
+     * the handful of inline tags we still allow (<b>, <i>, etc) get turned
+     * back on and balanced, and finally the highlighted code blocks are
+     * dropped back in
+     *
+     * @param string $body
+     * @return string
+     */
+    public static function format(string $body) : string {
+        $blocks = [];
+        $extracted = preg_replace_callback(
+            '#```([a-zA-Z0-9_+-]*)\r?\n(.*?)\r?\n?```#s',
+            function ($m) use (&$blocks) {
+                $lang = self::HIGHLIGHT_LANGS[strtolower($m[1])] ?? '';
+                $code = \Hl\highlight($lang, $m[2]);
+                $label = $m[1] !== '' ? '<div class="code-lang">' . esc($m[1]) . '</div>' : '';
+                $key = "\x01CB" . count($blocks) . "\x01";
+                $html = '<div class="code-block">' . $label
+                    . '<pre class="code"><code>' . $code . '</code></pre></div>';
+                $blocks['<p>' . $key . '</p>'] = $html;
+                $blocks[$key] = $html;
+                return "\n\n" . $key . "\n\n";
+            },
+            $body
+        );
+
+        $pd = new Parsedown();
+        $pd->setSafeMode(true);
+        $pd->setBreaksEnabled(true);
+        $html = $pd->text($extracted);
+
+        $tag_pattern = '#&lt;(/?)(' . implode('|', self::TAGS) . ')&gt;#i';
+        $html = preg_replace_callback($tag_pattern, function ($m) {
+            return '<' . $m[1] . strtolower($m[2]) . '>';
+        }, $html);
+        $html = self::balance($html);
+
+        $html = preg_replace('#<a (?![^>]*\brel=)#', '<a rel="nofollow noopener" ', $html);
+
+        return strtr($html, $blocks);
+    }
+
+    /**
+     * this function makes sure the allowed inline tags are balanced. it drops
+     * any stray closing tags and appends closers for whatevers left open, so a
+     * forgotten </b> in a post cant bleed out into the rest of the page
+     *
+     * @param string $html
+     * @return string
+     */
+    private static function balance(string $html) : string {
+        $counts = array_fill_keys(self::TAGS, 0);
+        $balanced = preg_replace_callback('#<(/?)([a-z0-9]+)>#i', function ($m) use (&$counts) {
+            $tag = strtolower($m[2]);
+            if (!in_array($tag, self::TAGS, true)) {
+                return $m[0];
+            }
+            if ($m[1] === '/') {
+                if ($counts[$tag] > 0) {
+                    $counts[$tag]--;
+                    return '</' . $tag . '>';
+                }
+                return '';
+            }
+            $counts[$tag]++;
+            return '<' . $tag . '>';
+        }, $html);
+
+        foreach ($counts as $tag => $open) {
+            $balanced .= str_repeat('</' . $tag . '>', $open);
+        }
+        return $balanced;
+    }
+}
diff --git a/lib/render.php b/lib/render.php
new file mode 100644
index 0000000..b1eb1a8
--- /dev/null
+++ b/lib/render.php
@@ -0,0 +1,59 @@
+<?php
+
+class Render {
+
+    /**
+     * this function sets the view that render() will include
+     *
+     * @param string $view
+     * @return void
+     */
+    public static function set_view(string $view) : void {
+        global $current_view;
+        $current_view = $view;
+    }
+
+    /**
+     * this function renders the current view. the $data array is unpacked into
+     * the view's scope, and the view can also reach the global $current_user.
+     * it does not return
+     *
+     * @param array $data
+     * @return never
+     */
+    public static function render(array $data = []) : never {
+        global $current_view, $current_user;
+        extract($data, EXTR_SKIP);
+        require PATH_TO_VIEWS_DIR . $current_view . '.php';
+        exit;
+    }
+
+    /**
+     * this function sends a json response and bails (used by the api-ish
+     * endpoints)
+     *
+     * @param array $payload
+     * @param int $code
+     * @return never
+     */
+    public static function render_json(array $payload, int $code = 200) : never {
+        http_response_code($code);
+        header('Content-Type: application/json; charset=utf-8');
+        echo json_encode($payload);
+        exit;
+    }
+
+    /**
+     * this function renders the error page for the given Error_Type and bails.
+     * the enum carries both the http status code and the page heading, so
+     * call sites just say `Render::render_error(Error_Type::NOT_FOUND)`
+     *
+     * @param Error_Type $type
+     * @return never
+     */
+    public static function render_error(Error_Type $type) : never {
+        http_response_code($type->value);
+        self::set_view('Error_View');
+        self::render(['error' => $type->message()]);
+    }
+}
diff --git a/lib/session.php b/lib/session.php
new file mode 100644
index 0000000..96585c9
--- /dev/null
+++ b/lib/session.php
@@ -0,0 +1,138 @@
+<?php
+
+class Session {
+
+    /**
+     * this function starts the session if one isnt already going. httponly +
+     * samesite=lax are always on; the secure flag is only set on https so the
+     * cookie still works over plain http in dev
+     *
+     * @return void
+     */
+    public static function boot() : void {
+        if (session_status() === PHP_SESSION_NONE) {
+            session_set_cookie_params([
+                'httponly' => true,
+                'samesite' => 'Lax',
+                'secure'   => !empty($_SERVER['HTTPS']),
+            ]);
+            session_start();
+        }
+    }
+
+    /**
+     * this function reads the session and returns the logged in user row, or
+     * null when nobody is logged in. this is the one place we read $_SESSION
+     * for identity; everything else uses the global $current_user
+     *
+     * @return array|null
+     */
+    public static function auth() : ?array {
+        if (empty($_SESSION['user_id'])) {
+            return null;
+        }
+        return Users_Model::get((int) $_SESSION['user_id']);
+    }
+
+    /**
+     * this function logs a user in, regenerating the session id first so a
+     * pre-login id cant be reused (session fixation)
+     *
+     * @param array $user
+     * @return void
+     */
+    public static function login(array $user) : void {
+        session_regenerate_id(true);
+        $_SESSION['user_id'] = $user['id'];
+    }
+
+    /**
+     * this function logs the current user out and tears the session down
+     *
+     * @return void
+     */
+    public static function logout() : void {
+        $_SESSION = [];
+        session_destroy();
+    }
+
+    /**
+     * this function returns the csrf token for this session, minting a fresh
+     * one the first time its asked for
+     *
+     * @return string
+     */
+    public static function csrf_token() : string {
+        if (empty($_SESSION['csrf'])) {
+            $_SESSION['csrf'] = bin2hex(random_bytes(32));
+        }
+        return $_SESSION['csrf'];
+    }
+
+    /**
+     * this function checks a submitted token against the session token with a
+     * constant time comparison
+     *
+     * @param mixed $token
+     * @return bool
+     */
+    public static function check_csrf(mixed $token) : bool {
+        return !empty($_SESSION['csrf'])
+            && is_string($token)
+            && hash_equals($_SESSION['csrf'], $token);
+    }
+
+    /**
+     * this function makes sure somebody is logged in, redirecting to /login if
+     * not, and otherwise hands back the current user row
+     *
+     * @return array
+     */
+    public static function require_login() : array {
+        global $current_user;
+        if (!$current_user) {
+            redirect('/login');
+        }
+        return $current_user;
+    }
+
+    /**
+     * this function gates an action to mods and admins, throwing a 403 page
+     * for anyone else
+     *
+     * @return array
+     */
+    public static function require_mod() : array {
+        global $current_user;
+        if (!role_of($current_user)->is_mod()) {
+            Render::render_error(Error_Type::FORBIDDEN);
+        }
+        return $current_user;
+    }
+
+    /**
+     * this function gates an action to admins only, throwing a 403 page for
+     * anyone else (mods included)
+     *
+     * @return array
+     */
+    public static function require_admin() : array {
+        global $current_user;
+        if (!role_of($current_user)->is_admin()) {
+            Render::render_error(Error_Type::FORBIDDEN);
+        }
+        return $current_user;
+    }
+
+    /**
+     * this function checks the csrf token on a POST and throws a 419 page if
+     * it doesnt match the session token
+     *
+     * @return void
+     */
+    public static function require_csrf() : void {
+        if (!self::check_csrf($_POST['csrf'] ?? null)) {
+            Render::render_error(Error_Type::INVALID_CSRF);
+        }
+    }
+}
diff --git a/lib/upload.php b/lib/upload.php
new file mode 100644
index 0000000..4730048
--- /dev/null
+++ b/lib/upload.php
@@ -0,0 +1,64 @@
+<?php
+
+class Upload {
+    const MAX_BYTES = 26214400;
+
+    const TYPES = [
+        IMAGETYPE_PNG  => 'png',
+        IMAGETYPE_JPEG => 'jpg',
+        IMAGETYPE_GIF  => 'gif',
+        IMAGETYPE_WEBP => 'webp',
+    ];
+
+    /**
+     * this function validates an uploaded file and, if its actually an image,
+     * moves it into the uploads dir under a random name and returns its url.
+     * the type is decided from the real image contents via getimagesize, never
+     * the filename, and only png/jpeg/gif/webp are allowed, so a script
+     * disguised as an image cant be stored (or later executed)
+     *
+     * @param array $file  one entry out of $_FILES
+     * @return array  ['url' => string] on success, or ['error' => string] on failure
+     */
+    public static function save(array $file) : array {
+        if (!isset($file['error']) || is_array($file['error'])) {
+            return ['error' => 'Invalid upload.'];
+        }
+        switch ($file['error']) {
+            case UPLOAD_ERR_OK:
+                break;
+            case UPLOAD_ERR_INI_SIZE:
+            case UPLOAD_ERR_FORM_SIZE:
+                return ['error' => 'That image is too large.'];
+            case UPLOAD_ERR_NO_FILE:
+                return ['error' => 'No file was uploaded.'];
+            default:
+                return ['error' => 'Upload failed.'];
+        }
+        if (($file['size'] ?? 0) > self::MAX_BYTES) {
+            return ['error' => 'That image exceeds the ' . (int) (self::MAX_BYTES / 1048576) . ' MB limit.'];
+        }
+        if (!is_uploaded_file($file['tmp_name'])) {
+            return ['error' => 'Invalid upload.'];
+        }
+
+        $info = @getimagesize($file['tmp_name']);
+        if ($info === false || !isset(self::TYPES[$info[2]])) {
+            return ['error' => 'Only PNG, JPEG, GIF, or WebP images are allowed.'];
+        }
+        $ext = self::TYPES[$info[2]];
+
+        if (!is_dir(PATH_TO_UPLOADS_DIR) && !mkdir(PATH_TO_UPLOADS_DIR, 0775, true) && !is_dir(PATH_TO_UPLOADS_DIR)) {
+            return ['error' => 'Could not store the image.'];
+        }
+
+        $name = bin2hex(random_bytes(16)) . '.' . $ext;
+        $dest = PATH_TO_UPLOADS_DIR . $name;
+        if (!move_uploaded_file($file['tmp_name'], $dest)) {
+            return ['error' => 'Could not store the image.'];
+        }
+        @chmod($dest, 0644);
+
+        return ['url' => '/uploads/' . $name];
+    }
+}
diff --git a/models/Boards_Model.php b/models/Boards_Model.php
new file mode 100644
index 0000000..e48727b
--- /dev/null
+++ b/models/Boards_Model.php
@@ -0,0 +1,141 @@
+<?php
+
+final class Boards_Model {
+
+    /**
+     * this function returns every board grouped by category (in board order), with
+     * the topic count, post count, and a bit of info about the most recent thread
+     * on each board for the forum index
+     *
+     * @return array  category => list of board rows
+     */
+    public static function list_grouped() : array {
+        $sql = <<<SQL
+            SELECT b.*,
+                   (SELECT COUNT(*) FROM threads t WHERE t.board_id = b.id) AS topic_count,
+                   (SELECT COUNT(*) FROM posts p
+                      JOIN threads t ON t.id = p.thread_id
+                     WHERE t.board_id = b.id) AS post_count,
+                   lt.id AS last_thread_id,
+                   lt.title AS last_title,
+                   lt.updated_at AS last_at,
+                   lu.username AS last_user
+            FROM boards b
+            LEFT JOIN threads lt ON lt.id = (
+                SELECT t.id FROM threads t
+                WHERE t.board_id = b.id
+                ORDER BY t.updated_at DESC, t.id DESC LIMIT 1
+            )
+            LEFT JOIN users lu ON lu.id = lt.user_id
+            ORDER BY b.position ASC
+        SQL;
+        $rows = Db::handle()->query($sql)->fetchAll();
+
+        $grouped = [];
+        foreach ($rows as $row) {
+            $grouped[$row['category']][] = $row;
+        }
+        return $grouped;
+    }
+
+    /**
+     * this function looks up a single board by its slug
+     *
+     * @param string $slug
+     * @return array|null
+     */
+    public static function get_by_slug(string $slug) : ?array {
+        $sth = Db::handle()->prepare("SELECT * FROM boards WHERE slug = ?");
+        $sth->execute([$slug]);
+        return $sth->fetch() ?: null;
+    }
+
+    /**
+     * this function counts how many threads live on a board (for pagination)
+     *
+     * @param int $board_id
+     * @return int
+     */
+    public static function thread_count(int $board_id) : int {
+        $sth = Db::handle()->prepare("SELECT COUNT(*) FROM threads WHERE board_id = ?");
+        $sth->execute([$board_id]);
+        return (int) $sth->fetchColumn();
+    }
+
+    /**
+     * this function lists every board in display order with its thread count, for
+     * the admin board manager
+     *
+     * @return array
+     */
+    public static function list_all() : array {
+        $sql = <<<SQL
+            SELECT b.*,
+                   (SELECT COUNT(*) FROM threads t WHERE t.board_id = b.id) AS thread_count
+            FROM boards b
+            ORDER BY b.position ASC, b.id ASC
+        SQL;
+        return Db::handle()->query($sql)->fetchAll();
+    }
+
+    /**
+     * this function looks up a single board by id
+     *
+     * @param int $id
+     * @return array|null
+     */
+    public static function get(int $id) : ?array {
+        $sth = Db::handle()->prepare("SELECT * FROM boards WHERE id = ?");
+        $sth->execute([$id]);
+        return $sth->fetch() ?: null;
+    }
+
+    /**
+     * this function creates a board and returns its new id
+     *
+     * @param string $slug
+     * @param string $name
+     * @param string $description
+     * @param string $category
+     * @param int $position
+     * @return int
+     */
+    public static function create(string $slug, string $name, string $description, string $category, int $position) : int {
+        $dbh = Db::handle();
+        $sth = $dbh->prepare(
+            "INSERT INTO boards (slug, name, description, category, position) VALUES (?, ?, ?, ?, ?)"
+        );
+        $sth->execute([$slug, $name, $description, $category, $position]);
+        return (int) $dbh->lastInsertId();
+    }
+
+    /**
+     * this function updates an existing board in place
+     *
+     * @param int $id
+     * @param string $slug
+     * @param string $name
+     * @param string $description
+     * @param string $category
+     * @param int $position
+     * @return void
+     */
+    public static function update(int $id, string $slug, string $name, string $description, string $category, int $position) : void {
+        $sth = Db::handle()->prepare(
+            "UPDATE boards SET slug = ?, name = ?, description = ?, category = ?, position = ? WHERE id = ?"
+        );
+        $sth->execute([$slug, $name, $description, $category, $position, $id]);
+    }
+
+    /**
+     * this function deletes a board. callers must check it has no threads first --
+     * the threads.board_id foreign key would otherwise block the delete
+     *
+     * @param int $id
+     * @return void
+     */
+    public static function delete(int $id) : void {
+        $sth = Db::handle()->prepare("DELETE FROM boards WHERE id = ?");
+        $sth->execute([$id]);
+    }
+}
diff --git a/models/Posts_Model.php b/models/Posts_Model.php
new file mode 100644
index 0000000..b88b357
--- /dev/null
+++ b/models/Posts_Model.php
@@ -0,0 +1,82 @@
+<?php
+
+final class Posts_Model {
+
+    /**
+     * this function returns all posts in a thread oldest first, each with the
+     * author username and role (so we can show the role badge)
+     *
+     * @param int $thread_id
+     * @return array
+     */
+    public static function list_for_thread(int $thread_id) : array {
+        $sql = <<<SQL
+            SELECT p.*, u.username, u.role
+            FROM posts p
+            JOIN users u ON u.id = p.user_id
+            WHERE p.thread_id = ?
+            ORDER BY p.created_at ASC, p.id ASC
+        SQL;
+        $sth = Db::handle()->prepare($sql);
+        $sth->execute([$thread_id]);
+        return $sth->fetchAll();
+    }
+
+    /**
+     * this function fetches a single post row by id
+     *
+     * @param int $id
+     * @return array|null
+     */
+    public static function get(int $id) : ?array {
+        $sth = Db::handle()->prepare("SELECT * FROM posts WHERE id = ?");
+        $sth->execute([$id]);
+        return $sth->fetch() ?: null;
+    }
+
+    /**
+     * this function returns the id of the first (OP) post in a thread, which we use
+     * to tell the original post apart from replies
+     *
+     * @param int $thread_id
+     * @return int
+     */
+    public static function first_id(int $thread_id) : int {
+        $sth = Db::handle()->prepare(
+            "SELECT id FROM posts WHERE thread_id = ? ORDER BY created_at ASC, id ASC LIMIT 1"
+        );
+        $sth->execute([$thread_id]);
+        return (int) $sth->fetchColumn();
+    }
+
+    /**
+     * this function adds a reply to a thread and bumps the thread's updated_at so
+     * it floats back to the top of the board
+     *
+     * @param int $thread_id
+     * @param int $user_id
+     * @param string $body
+     * @return int  the new post id
+     */
+    public static function create(int $thread_id, int $user_id, string $body) : int {
+        $dbh = Db::handle();
+        $now = time();
+        $sth = $dbh->prepare(
+            "INSERT INTO posts (thread_id, user_id, body, created_at) VALUES (?, ?, ?, ?)"
+        );
+        $sth->execute([$thread_id, $user_id, $body, $now]);
+        $dbh->prepare("UPDATE threads SET updated_at = ? WHERE id = ?")->execute([$now, $thread_id]);
+        return (int) $dbh->lastInsertId();
+    }
+
+    /**
+     * this function deletes a single post (a reply)
+     *
+     * @param int $post_id
+     * @return void
+     */
+    public static function delete(int $post_id) : void {
+        $sth = Db::handle()->prepare("DELETE FROM posts WHERE id = ?");
+        $sth->execute([$post_id]);
+    }
+}
diff --git a/models/Search_Model.php b/models/Search_Model.php
new file mode 100644
index 0000000..aa54a31
--- /dev/null
+++ b/models/Search_Model.php
@@ -0,0 +1,72 @@
+<?php
+
+final class Search_Model {
+
+    /**
+     * this function turns a raw search box query into an fts5 match string. it
+     * strips each term down to letters/numbers and tacks on a * for prefix
+     * matching, which also keeps arbitrary fts syntax from leaking through
+     *
+     * @param string $q
+     * @return string
+     */
+    public static function fts_query(string $q) : string {
+        $terms = preg_split('/\s+/u', trim($q), -1, PREG_SPLIT_NO_EMPTY);
+        $clean = [];
+        foreach ($terms as $t) {
+            $t = preg_replace('/[^\p{L}\p{N}_]+/u', '', $t);
+            if ($t !== '') {
+                $clean[] = $t . '*';
+            }
+        }
+        return implode(' ', $clean);
+    }
+
+    /**
+     * this function runs a full text search over threads + posts and returns the
+     * best matches, deduped to one row per thread. the snippet is built with
+     * char(2)/char(3) markers which we then escape and swap for <mark> tags, so the
+     * highlight is safe even though the body isnt
+     *
+     * @param string $q
+     * @param int $limit
+     * @return array
+     */
+    public static function search_posts(string $q, int $limit = 30) : array {
+        $match = self::fts_query($q);
+        if ($match === '') {
+            return [];
+        }
+        $sql = <<<SQL
+            SELECT thread_id, board_slug, title,
+                   snippet(search_fts, 1, char(2), char(3), '…', 14) AS snip
+            FROM search_fts
+            WHERE search_fts MATCH :q
+            ORDER BY bm25(search_fts)
+            LIMIT 80
+        SQL;
+        $sth = Db::handle()->prepare($sql);
+        $sth->execute([':q' => $match]);
+
+        $seen = [];
+        $out = [];
+        foreach ($sth->fetchAll() as $row) {
+            $tid = (int) $row['thread_id'];
+            if (isset($seen[$tid])) {
+                continue;
+            }
+            $seen[$tid] = true;
+            $snip = str_replace([chr(2), chr(3)], ['<mark>', '</mark>'], esc($row['snip']));
+            $out[] = [
+                'thread_id' => $tid,
+                'board_slug' => $row['board_slug'],
+                'title' => $row['title'],
+                'snippet' => $snip,
+            ];
+            if (count($out) >= $limit) {
+                break;
+            }
+        }
+        return $out;
+    }
+}
diff --git a/models/Threads_Model.php b/models/Threads_Model.php
new file mode 100644
index 0000000..05b75f1
--- /dev/null
+++ b/models/Threads_Model.php
@@ -0,0 +1,149 @@
+<?php
+
+final class Threads_Model {
+
+    /**
+     * this function loads a single thread along with its author username and the
+     * slug/name of the board it lives on
+     *
+     * @param int $id
+     * @return array|null
+     */
+    public static function get(int $id) : ?array {
+        $sql = <<<SQL
+            SELECT t.*, u.username, b.slug AS board_slug, b.name AS board_name
+            FROM threads t
+            JOIN users u ON u.id = t.user_id
+            LEFT JOIN boards b ON b.id = t.board_id
+            WHERE t.id = ?
+        SQL;
+        $sth = Db::handle()->prepare($sql);
+        $sth->execute([$id]);
+        return $sth->fetch() ?: null;
+    }
+
+    /**
+     * this function returns a page of threads for a board, pinned ones first then
+     * most recently active, each with its post count
+     *
+     * @param int $board_id
+     * @param int $limit
+     * @param int $offset
+     * @return array
+     */
+    public static function list_in_board(int $board_id, int $limit = 40, int $offset = 0) : array {
+        $sql = <<<SQL
+            SELECT t.*, u.username,
+                   (SELECT COUNT(*) FROM posts p WHERE p.thread_id = t.id) AS post_count
+            FROM threads t
+            JOIN users u ON u.id = t.user_id
+            WHERE t.board_id = :board
+            ORDER BY t.pinned DESC, t.updated_at DESC
+            LIMIT :limit OFFSET :offset
+        SQL;
+        $sth = Db::handle()->prepare($sql);
+        $sth->bindValue(':board', $board_id, PDO::PARAM_INT);
+        $sth->bindValue(':limit', $limit, PDO::PARAM_INT);
+        $sth->bindValue(':offset', $offset, PDO::PARAM_INT);
+        $sth->execute();
+        return $sth->fetchAll();
+    }
+
+    /**
+     * this function creates a thread and its opening post together in one
+     * transaction, so we never end up with a thread that has no OP
+     *
+     * @param int $board_id
+     * @param int $user_id
+     * @param string $title
+     * @param string $body
+     * @return int  the new thread id
+     */
+    public static function create(int $board_id, int $user_id, string $title, string $body) : int {
+        $dbh = Db::handle();
+        $now = time();
+        $dbh->beginTransaction();
+        try {
+            $sth = $dbh->prepare(
+                "INSERT INTO threads (board_id, user_id, title, created_at, updated_at)
+                 VALUES (?, ?, ?, ?, ?)"
+            );
+            $sth->execute([$board_id, $user_id, $title, $now, $now]);
+            $thread_id = (int) $dbh->lastInsertId();
+            $sth = $dbh->prepare(
+                "INSERT INTO posts (thread_id, user_id, body, created_at) VALUES (?, ?, ?, ?)"
+            );
+            $sth->execute([$thread_id, $user_id, $body, $now]);
+            $dbh->commit();
+            return $thread_id;
+        } catch (Throwable $e) {
+            $dbh->rollBack();
+            throw $e;
+        }
+    }
+
+    /**
+     * this function pins or unpins a thread
+     *
+     * @param int $thread_id
+     * @param bool $pinned
+     * @return void
+     */
+    public static function set_pinned(int $thread_id, bool $pinned) : void {
+        $sth = Db::handle()->prepare("UPDATE threads SET pinned = ? WHERE id = ?");
+        $sth->execute([$pinned ? 1 : 0, $thread_id]);
+    }
+
+    /**
+     * this function locks or unlocks a thread
+     *
+     * @param int $thread_id
+     * @param bool $locked
+     * @return void
+     */
+    public static function set_locked(int $thread_id, bool $locked) : void {
+        $sth = Db::handle()->prepare("UPDATE threads SET locked = ? WHERE id = ?");
+        $sth->execute([$locked ? 1 : 0, $thread_id]);
+    }
+
+    /**
+     * this function deletes a thread and all of its posts in one transaction. the
+     * post deletes also fire the search triggers, so the index stays clean
+     *
+     * @param int $thread_id
+     * @return void
+     */
+    public static function delete(int $thread_id) : void {
+        $dbh = Db::handle();
+        $dbh->beginTransaction();
+        try {
+            $dbh->prepare("DELETE FROM posts WHERE thread_id = ?")->execute([$thread_id]);
+            $dbh->prepare("DELETE FROM threads WHERE id = ?")->execute([$thread_id]);
+            $dbh->commit();
+        } catch (Throwable $e) {
+            $dbh->rollBack();
+            throw $e;
+        }
+    }
+
+    /**
+     * this function lists the threads a user started (newest first) with their
+     * board and post count, for the profile page
+     *
+     * @param int $user_id
+     * @return array
+     */
+    public static function list_by_user(int $user_id) : array {
+        $sql = <<<SQL
+            SELECT t.id, t.title, t.created_at, t.updated_at, b.slug AS board_slug, b.name AS board_name,
+                   (SELECT COUNT(*) FROM posts p WHERE p.thread_id = t.id) AS post_count
+            FROM threads t
+            LEFT JOIN boards b ON b.id = t.board_id
+            WHERE t.user_id = ?
+            ORDER BY t.created_at DESC
+        SQL;
+        $sth = Db::handle()->prepare($sql);
+        $sth->execute([$user_id]);
+        return $sth->fetchAll();
+    }
+}
diff --git a/models/Users_Model.php b/models/Users_Model.php
new file mode 100644
index 0000000..641cc2b
--- /dev/null
+++ b/models/Users_Model.php
@@ -0,0 +1,102 @@
+<?php
+
+final class Users_Model {
+
+    /**
+     * this function fetches a user row by id
+     *
+     * @param int $id
+     * @return array|null
+     */
+    public static function get(int $id) : ?array {
+        $sth = Db::handle()->prepare("SELECT * FROM users WHERE id = ?");
+        $sth->execute([$id]);
+        return $sth->fetch() ?: null;
+    }
+
+    /**
+     * this function fetches a user row by username (used for login + profiles)
+     *
+     * @param string $username
+     * @return array|null
+     */
+    public static function get_by_name(string $username) : ?array {
+        $sth = Db::handle()->prepare("SELECT * FROM users WHERE username = ?");
+        $sth->execute([$username]);
+        return $sth->fetch() ?: null;
+    }
+
+    /**
+     * this function counts the users. registration uses it to decide the very first
+     * account becomes the admin
+     *
+     * @return int
+     */
+    public static function count() : int {
+        return (int) Db::handle()->query("SELECT COUNT(*) FROM users")->fetchColumn();
+    }
+
+    /**
+     * this function creates a user with a hashed password and a role (defaults to
+     * normal)
+     *
+     * @param string $username
+     * @param string $password_hash
+     * @param string $role
+     * @return int  the new user id
+     */
+    public static function create(string $username, string $password_hash, string $role = 'normal') : int {
+        $dbh = Db::handle();
+        $sth = $dbh->prepare(
+            "INSERT INTO users (username, password_hash, role, created_at) VALUES (?, ?, ?, ?)"
+        );
+        $sth->execute([$username, $password_hash, $role, time()]);
+        return (int) $dbh->lastInsertId();
+    }
+
+    /**
+     * this function lists every user with their role, join date, and thread + post
+     * counts for the admin panel
+     *
+     * @return array
+     */
+    public static function list_all() : array {
+        $sql = <<<SQL
+            SELECT u.id, u.username, u.role, u.created_at,
+                   (SELECT COUNT(*) FROM threads t WHERE t.user_id = u.id) AS thread_count,
+                   (SELECT COUNT(*) FROM posts p WHERE p.user_id = u.id) AS post_count
+            FROM users u
+            ORDER BY u.created_at ASC
+        SQL;
+        return Db::handle()->query($sql)->fetchAll();
+    }
+
+    /**
+     * this function sets a user's role (normal/mod/admin)
+     *
+     * @param int $user_id
+     * @param string $role
+     * @return void
+     */
+    public static function set_role(int $user_id, string $role) : void {
+        $sth = Db::handle()->prepare("UPDATE users SET role = ? WHERE id = ?");
+        $sth->execute([$role, $user_id]);
+    }
+
+    /**
+     * this function returns a user's thread and post totals for their profile
+     *
+     * @param int $user_id
+     * @return array  ['threads' => int, 'posts' => int]
+     */
+    public static function get_stats(int $user_id) : array {
+        $sql = <<<SQL
+            SELECT (SELECT COUNT(*) FROM threads WHERE user_id = :id) AS threads,
+                   (SELECT COUNT(*) FROM posts WHERE user_id = :id) AS posts
+        SQL;
+        $sth = Db::handle()->prepare($sql);
+        $sth->execute([':id' => $user_id]);
+        $row = $sth->fetch();
+        return ['threads' => (int) $row['threads'], 'posts' => (int) $row['posts']];
+    }
+}
diff --git a/public/css/style.css b/public/css/style.css
new file mode 100644
index 0000000..dd60c24
--- /dev/null
+++ b/public/css/style.css
@@ -0,0 +1,1127 @@
+@font-face {
+    font-family: 'IosevkaNerdFontMono';
+    src: url('/fonts/IosevkaNerdFontMono-Regular.ttf') format('truetype');
+    font-weight: 400;
+    font-style: normal;
+}
+
+@font-face {
+    font-family: 'IosevkaNerdFontMono';
+    src: url('/fonts/IosevkaNerdFontMono-Bold.ttf') format('truetype');
+    font-weight: 700;
+    font-style: normal;
+}
+
+@font-face {
+    font-family: 'IosevkaNerdFontMono';
+    src: url('/fonts/IosevkaNerdFontMono-Italic.ttf') format('truetype');
+    font-weight: 400;
+    font-style: italic;
+}
+
+:root {
+    --bg: #1a1b26;
+    --bg-secondary: #1f2335;
+    --bg-tertiary: #292e42;
+    --fg: #c0caf5;
+    --fg-muted: #a9b1d6;
+    --fg-subtle: #565f89;
+    --border: #292e42;
+    --link: #7aa2f7;
+    --link-hover: #7dcfff;
+    --green: #9ece6a;
+    --red: #f7768e;
+    --yellow: #e0af68;
+    --orange: #ff9e64;
+    --purple: #bb9af7;
+    --cyan: #7dcfff;
+
+    --ml-base: #1f2335;
+    --ml-mid: #3b4261;
+    --ml-accent: #7aa2f7;
+    --ml-accent-fg: #1a1b26;
+
+    --tok-keyword: #bb9af7;
+    --tok-type:    #2ac3de;
+    --tok-builtin: #7aa2f7;
+    --tok-number:  #ff9e64;
+    --tok-string:  #9ece6a;
+    --tok-char:    #9ece6a;
+    --tok-comment: #565f89;
+    --tok-preproc: #7dcfff;
+    --tok-op:      #89ddff;
+    --tok-punct:   #a9b1d6;
+}
+
+.t-kw  { color: var(--tok-keyword); }
+.t-ty  { color: var(--tok-type); }
+.t-bi  { color: var(--tok-builtin); }
+.t-num { color: var(--tok-number); }
+.t-str { color: var(--tok-string); }
+.t-chr { color: var(--tok-char); }
+.t-cmt { color: var(--tok-comment); font-style: italic; }
+.t-pp  { color: var(--tok-preproc); }
+.t-op  { color: var(--tok-op); }
+.t-pun { color: var(--tok-punct); }
+
+.code-block {
+    margin: 1rem 0;
+    border: 1px solid var(--border);
+    border-radius: 4px;
+    overflow: hidden;
+}
+
+.code-lang {
+    padding: 0.25rem 0.75rem;
+    background: var(--bg-tertiary);
+    border-bottom: 1px solid var(--border);
+    color: var(--fg-subtle);
+    font-size: 0.75rem;
+}
+
+pre.code {
+    margin: 0;
+    padding: 0.85rem 1rem;
+    background: var(--bg-secondary);
+    border-radius: 0;
+    overflow-x: auto;
+    line-height: 1.45;
+    tab-size: 4;
+    -moz-tab-size: 4;
+}
+
+pre.code code {
+    background: none;
+    padding: 0;
+}
+
+* {
+    margin: 0;
+    padding: 0;
+    box-sizing: border-box;
+}
+
+html {
+    font-size: 14px;
+}
+
+body {
+    font-family: 'IosevkaNerdFontMono', ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+    background: var(--bg);
+    color: var(--fg);
+    line-height: 1.5;
+    min-height: 100vh;
+    padding-bottom: 2.5rem;
+}
+
+a {
+    color: var(--link);
+    text-decoration: none;
+}
+
+a:hover {
+    color: var(--link-hover);
+    text-decoration: underline;
+}
+
+header {
+    background: var(--bg-secondary);
+    border-bottom: 1px solid var(--border);
+    padding: 1rem 2rem;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    flex-wrap: wrap;
+    gap: 1rem;
+}
+
+header .logo {
+    font-weight: bold;
+    font-size: 1.1rem;
+}
+
+.nav-right {
+    display: flex;
+    align-items: center;
+    gap: 1.25rem;
+}
+
+.nav-right a {
+    color: var(--fg-muted);
+}
+
+.nav-right a:hover {
+    color: var(--fg);
+}
+
+.inline-form {
+    display: inline;
+}
+
+.link-button {
+    background: none;
+    border: none;
+    color: var(--fg-muted);
+    font: inherit;
+    cursor: pointer;
+    padding: 0;
+}
+
+.link-button:hover {
+    color: var(--fg);
+    text-decoration: underline;
+}
+
+main {
+    max-width: 1100px;
+    margin: 0 auto;
+    padding: 1.5rem 1rem;
+}
+
+footer.modeline {
+    position: fixed;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    z-index: 50;
+    background: var(--bg);
+    border-top: 1px solid var(--border);
+    font-size: 0.9rem;
+    line-height: 1.8;
+    overflow: hidden;
+}
+
+.ml-row {
+    display: flex;
+    align-items: stretch;
+    white-space: nowrap;
+}
+
+.ml-a, .ml-b, .ml-c {
+    padding: 0 0.6rem;
+}
+
+.ml-a {
+    background: var(--ml-accent);
+    color: var(--ml-accent-fg);
+    font-weight: 700;
+}
+
+.ml-b {
+    background: var(--ml-mid);
+    color: var(--fg-muted);
+}
+
+.ml-c {
+    background: var(--ml-base);
+    color: var(--fg-muted);
+}
+
+.ml-fill {
+    flex: 1;
+    background: var(--bg);
+    min-width: 1rem;
+}
+
+.ml-sep::before {
+    display: inline-block;
+}
+
+.ml-fwd::before { content: "\e0b0"; }
+.ml-bwd::before { content: "\e0b2"; }
+
+.sep-a-b   { color: var(--ml-accent); background: var(--ml-mid); }
+.sep-b-c   { color: var(--ml-mid);    background: var(--ml-base); }
+.sep-c-bar { color: var(--ml-base);   background: var(--bg); }
+.sep-bar-c { color: var(--ml-base);   background: var(--bg); }
+.sep-c-b   { color: var(--ml-mid);    background: var(--ml-base); }
+.sep-b-a   { color: var(--ml-accent); background: var(--ml-mid); }
+
+h1, h2, h3 {
+    font-weight: 600;
+    margin-bottom: 1rem;
+}
+
+h1 { font-size: 1.5rem; }
+h2 { font-size: 1.25rem; color: var(--fg-muted); }
+h3 { font-size: 1.1rem; }
+
+.empty {
+    color: var(--fg-muted);
+    padding: 2rem 0;
+}
+
+table {
+    width: 100%;
+    border-collapse: collapse;
+    margin: 1rem 0;
+}
+
+th, td {
+    text-align: left;
+    padding: 0.5rem 0.5rem;
+    border-bottom: 1px solid var(--border);
+}
+
+th {
+    background: var(--bg-secondary);
+    color: var(--fg-muted);
+    font-weight: 600;
+}
+
+tbody tr:hover {
+    background: var(--bg-secondary);
+}
+
+table.thread-list {
+    table-layout: fixed;
+}
+
+table.thread-list th:nth-child(1),
+table.thread-list td:nth-child(1) {
+    width: 55%;
+}
+
+.thread-list .title {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.thread-list .author {
+    color: var(--fg-muted);
+}
+
+.thread-list .replies,
+.thread-list .activity {
+    color: var(--fg-subtle);
+    white-space: nowrap;
+}
+
+.thread-list .activity {
+    text-align: right;
+}
+
+.breadcrumb {
+    margin-bottom: 1rem;
+    color: var(--fg-muted);
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.thread-header {
+    margin-bottom: 1.5rem;
+    padding-bottom: 1rem;
+    border-bottom: 1px solid var(--border);
+}
+
+.thread-header h1 {
+    margin-bottom: 0.25rem;
+}
+
+.thread-meta,
+.profile-meta {
+    color: var(--fg-subtle);
+    font-size: 0.9rem;
+}
+
+.posts {
+    display: flex;
+    flex-direction: column;
+    gap: 1rem;
+}
+
+.post {
+    background: var(--bg-secondary);
+    border: 1px solid var(--border);
+    border-radius: 4px;
+}
+
+.post-meta {
+    display: flex;
+    align-items: center;
+    gap: 0.75rem;
+    padding: 0.5rem 1rem;
+    background: var(--bg-tertiary);
+    border-bottom: 1px solid var(--border);
+    border-radius: 4px 4px 0 0;
+    font-size: 0.9rem;
+    color: var(--fg-subtle);
+}
+
+.post-author {
+    font-weight: 600;
+}
+
+.post-anchor {
+    margin-left: auto;
+    color: var(--fg-subtle);
+}
+
+.badge {
+    background: var(--bg);
+    border: 1px solid var(--border);
+    border-radius: 3px;
+    padding: 0 0.4rem;
+    font-size: 0.75rem;
+    color: var(--green);
+}
+
+.post-body {
+    padding: 1rem;
+    word-wrap: break-word;
+    overflow-wrap: anywhere;
+}
+
+.post-body > *:first-child { margin-top: 0; }
+.post-body > *:last-child { margin-bottom: 0; }
+
+.post-body p { margin: 0.75rem 0; }
+
+.post-body h1,
+.post-body h2,
+.post-body h3,
+.post-body h4 {
+    margin: 1.25rem 0 0.5rem;
+    color: var(--fg);
+}
+
+.post-body h1 { font-size: 1.4rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; }
+.post-body h2 { font-size: 1.2rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; }
+.post-body h3 { font-size: 1.05rem; }
+
+.post-body ul,
+.post-body ol {
+    margin: 0.75rem 0;
+    padding-left: 1.75rem;
+}
+
+.post-body li { margin: 0.2rem 0; }
+
+.post-body blockquote {
+    border-left: 3px solid var(--border);
+    margin: 0.75rem 0;
+    padding-left: 1rem;
+    color: var(--fg-muted);
+}
+
+.post-body code {
+    background: var(--bg-tertiary);
+    padding: 0.1rem 0.35rem;
+    border-radius: 3px;
+}
+
+.post-body pre.code code {
+    background: none;
+    padding: 0;
+}
+
+.post-body hr {
+    border: none;
+    border-top: 1px solid var(--border);
+    margin: 1.5rem 0;
+}
+
+.post-body table {
+    margin: 0.75rem 0;
+    width: auto;
+}
+
+.post-body img { max-width: 100%; }
+
+mark {
+    background: var(--yellow);
+    color: var(--bg);
+    border-radius: 2px;
+    padding: 0 0.1rem;
+}
+
+.post:target {
+    border-color: var(--link);
+}
+
+.reply-form,
+.stacked-form {
+    display: flex;
+    flex-direction: column;
+    gap: 0.5rem;
+    margin-top: 2rem;
+}
+
+.reply-form h3 {
+    margin-bottom: 0.25rem;
+}
+
+label {
+    color: var(--fg-muted);
+    font-size: 0.9rem;
+}
+
+input[type="text"],
+input[type="password"],
+textarea {
+    font-family: inherit;
+    font-size: 1rem;
+    background: var(--bg);
+    color: var(--fg);
+    border: 1px solid var(--border);
+    border-radius: 4px;
+    padding: 0.6rem 0.75rem;
+    width: 100%;
+}
+
+input:focus,
+textarea:focus {
+    outline: none;
+    border-color: var(--link);
+}
+
+textarea {
+    resize: vertical;
+    line-height: 1.5;
+    tab-size: 4;
+    -moz-tab-size: 4;
+}
+
+button[type="submit"] {
+    align-self: flex-start;
+    font-family: inherit;
+    font-size: 1rem;
+    background: var(--bg-tertiary);
+    color: var(--fg);
+    border: 1px solid var(--border);
+    border-radius: 4px;
+    padding: 0.5rem 1.25rem;
+    cursor: pointer;
+    margin-top: 0.25rem;
+}
+
+button[type="submit"]:hover {
+    background: var(--border);
+    border-color: var(--fg-subtle);
+}
+
+.login-prompt {
+    margin-top: 2rem;
+    padding: 1rem;
+    background: var(--bg-secondary);
+    border: 1px solid var(--border);
+    border-radius: 4px;
+    color: var(--fg-muted);
+}
+
+.auth-form {
+    max-width: 420px;
+    margin: 2rem auto;
+}
+
+.auth-alt {
+    margin-top: 1.5rem;
+    color: var(--fg-muted);
+    font-size: 0.9rem;
+}
+
+.form-errors {
+    list-style: none;
+    background: rgba(248, 81, 73, 0.1);
+    border: 1px solid var(--red);
+    border-radius: 4px;
+    padding: 0.75rem 1rem;
+    margin-bottom: 1.5rem;
+    color: var(--red);
+}
+
+.form-errors li {
+    margin: 0.2rem 0;
+}
+
+.profile-header {
+    margin-bottom: 1.5rem;
+    padding-bottom: 1rem;
+    border-bottom: 1px solid var(--border);
+}
+
+.profile-header h1 {
+    margin-bottom: 0.25rem;
+}
+
+.pagination {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-top: 2rem;
+    padding-top: 1rem;
+    border-top: 1px solid var(--border);
+    color: var(--fg-subtle);
+}
+
+.search-form {
+    display: flex;
+    gap: 0.5rem;
+    margin-bottom: 1.5rem;
+}
+
+.search-form input {
+    flex: 1;
+    font-family: inherit;
+    font-size: 1rem;
+    background: var(--bg);
+    color: var(--fg);
+    border: 1px solid var(--border);
+    border-radius: 4px;
+    padding: 0.6rem 0.75rem;
+}
+
+.search-form input:focus {
+    outline: none;
+    border-color: var(--link);
+}
+
+.search-form button {
+    font-family: inherit;
+    background: var(--bg-tertiary);
+    color: var(--fg);
+    border: 1px solid var(--border);
+    border-radius: 4px;
+    padding: 0.6rem 1.25rem;
+    cursor: pointer;
+}
+
+.search-count {
+    color: var(--fg-subtle);
+    margin-bottom: 1rem;
+}
+
+.search-results {
+    list-style: none;
+}
+
+.search-results li {
+    padding: 0.75rem 0;
+    border-bottom: 1px solid var(--border);
+}
+
+.result-title {
+    font-weight: 600;
+}
+
+.result-board {
+    margin-left: 0.6rem;
+    font-size: 0.8rem;
+    color: var(--fg-subtle);
+}
+
+.result-snippet {
+    margin-top: 0.25rem;
+    color: var(--fg-muted);
+    font-size: 0.9rem;
+}
+
+.tele-overlay {
+    display: none;
+    position: fixed;
+    inset: 0;
+    z-index: 100;
+    background: rgba(0, 0, 0, 0.55);
+    padding-top: 8vh;
+    justify-content: center;
+}
+
+.tele-overlay.tele-visible {
+    display: flex;
+}
+
+.tele {
+    width: min(760px, 92vw);
+    height: min(460px, 70vh);
+    display: flex;
+    flex-direction: column;
+    background: var(--bg-secondary);
+    border: 1px solid var(--ml-accent);
+    border-radius: 6px;
+    overflow: hidden;
+    box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
+}
+
+.tele-input {
+    font-family: inherit;
+    font-size: 1.05rem;
+    background: var(--bg);
+    color: var(--fg);
+    border: none;
+    border-bottom: 1px solid var(--border);
+    padding: 0.85rem 1rem;
+    outline: none;
+}
+
+.tele-body {
+    flex: 1;
+    display: flex;
+    min-height: 0;
+}
+
+.tele-list {
+    list-style: none;
+    width: 45%;
+    overflow-y: auto;
+    border-right: 1px solid var(--border);
+}
+
+.tele-item {
+    padding: 0.5rem 0.85rem;
+    cursor: pointer;
+    border-left: 2px solid transparent;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.tele-item-title {
+    color: var(--fg);
+}
+
+.tele-item-board {
+    margin-left: 0.5rem;
+    font-size: 0.75rem;
+    color: var(--fg-subtle);
+}
+
+.tele-active {
+    background: var(--bg-tertiary);
+    border-left-color: var(--ml-accent);
+}
+
+.tele-empty {
+    padding: 0.85rem;
+    color: var(--fg-subtle);
+}
+
+.tele-preview {
+    flex: 1;
+    padding: 1rem;
+    overflow-y: auto;
+}
+
+.tele-prev-title {
+    font-weight: 700;
+    margin-bottom: 0.5rem;
+}
+
+.tele-prev-snippet {
+    color: var(--fg-muted);
+    font-size: 0.9rem;
+    line-height: 1.6;
+}
+
+.tele-footer {
+    display: flex;
+    gap: 1.25rem;
+    padding: 0.4rem 1rem;
+    background: var(--ml-base);
+    border-top: 1px solid var(--border);
+    color: var(--fg-subtle);
+    font-size: 0.8rem;
+}
+
+.error {
+    text-align: center;
+    padding: 4rem 2rem;
+}
+
+.error h1 {
+    color: var(--red);
+}
+
+.board-category {
+    margin-bottom: 2.5rem;
+}
+
+.board-category h2 {
+    border-bottom: 1px solid var(--border);
+    padding-bottom: 0.5rem;
+    margin-bottom: 0.5rem;
+}
+
+table.board-list {
+    table-layout: fixed;
+}
+
+.board-list .col-board { width: 50%; }
+.board-list .col-num   { width: 9%; text-align: center; color: var(--fg-subtle); }
+.board-list .col-last  { width: 32%; }
+
+.board-list th.col-num { text-align: center; }
+
+.board-list td {
+    vertical-align: top;
+    padding: 0.6rem 0.5rem;
+}
+
+.board-name {
+    font-weight: 600;
+    font-size: 1.05rem;
+}
+
+.board-desc {
+    color: var(--fg-muted);
+    font-size: 0.9rem;
+    margin-top: 0.15rem;
+}
+
+.last-title {
+    display: inline-block;
+    max-width: 100%;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    vertical-align: bottom;
+}
+
+.last-meta {
+    color: var(--fg-subtle);
+    font-size: 0.85rem;
+    margin-top: 0.15rem;
+}
+
+.muted {
+    color: var(--fg-subtle);
+}
+
+.board-header {
+    margin-bottom: 1.5rem;
+    padding-bottom: 1rem;
+    border-bottom: 1px solid var(--border);
+}
+
+.board-header h1 {
+    margin-bottom: 0.25rem;
+}
+
+.board-header .board-desc {
+    margin-bottom: 0.75rem;
+}
+
+.button-link {
+    display: inline-block;
+    background: var(--bg-tertiary);
+    color: var(--fg);
+    border: 1px solid var(--border);
+    border-radius: 4px;
+    padding: 0.35rem 1rem;
+    font-size: 0.9rem;
+}
+
+.button-link:hover {
+    background: var(--border);
+    border-color: var(--fg-subtle);
+    text-decoration: none;
+}
+
+.badge {
+    background: var(--bg);
+    border: 1px solid var(--border);
+    border-radius: 3px;
+    padding: 0 0.4rem;
+    font-size: 0.75rem;
+    color: var(--green);
+    vertical-align: middle;
+}
+
+.badge-pin {
+    color: var(--yellow);
+    border-color: var(--yellow);
+}
+
+.badge-lock {
+    color: var(--red);
+    border-color: var(--red);
+}
+
+.role-badge {
+    border-radius: 3px;
+    padding: 0 0.35rem;
+    font-size: 0.7rem;
+    margin-left: 0.35rem;
+    vertical-align: middle;
+}
+
+.role-admin {
+    color: var(--red);
+    border: 1px solid var(--red);
+}
+
+.role-mod {
+    color: var(--green);
+    border: 1px solid var(--green);
+}
+
+.mod-bar {
+    display: flex;
+    align-items: center;
+    gap: 1rem;
+    margin-top: 0.75rem;
+    padding: 0.5rem 0.75rem;
+    background: var(--bg-secondary);
+    border: 1px solid var(--border);
+    border-radius: 4px;
+    font-size: 0.85rem;
+    width: fit-content;
+}
+
+.mod-label {
+    color: var(--fg-subtle);
+    text-transform: uppercase;
+    letter-spacing: 0.05em;
+    font-size: 0.7rem;
+}
+
+.link-button.danger {
+    color: var(--red);
+}
+
+.post-delete {
+    margin-left: 0.5rem;
+}
+
+.preview {
+    margin: 1.5rem 0;
+    border: 1px solid var(--border);
+    border-radius: 4px;
+    background: var(--bg-secondary);
+}
+
+.preview-label {
+    padding: 0.4rem 1rem;
+    background: var(--bg-tertiary);
+    border-bottom: 1px solid var(--border);
+    border-radius: 4px 4px 0 0;
+    color: var(--fg-subtle);
+    text-transform: uppercase;
+    letter-spacing: 0.05em;
+    font-size: 0.7rem;
+}
+
+.preview .post-body {
+    padding: 1rem;
+}
+
+.markup-hint {
+    color: var(--fg-subtle);
+    font-size: 0.8rem;
+    margin-top: -0.1rem;
+}
+
+.markup-hint code {
+    background: var(--bg-tertiary);
+    padding: 0 0.25rem;
+    font-size: 0.85em;
+}
+
+.form-actions {
+    display: flex;
+    gap: 0.75rem;
+    margin-top: 0.25rem;
+}
+
+button.secondary {
+    background: transparent;
+    color: var(--fg-muted);
+}
+
+button.secondary:hover {
+    background: var(--bg-secondary);
+    color: var(--fg);
+}
+
+.role-form {
+    display: flex;
+    gap: 0.5rem;
+    align-items: center;
+}
+
+.role-form select {
+    font-family: inherit;
+    font-size: 0.9rem;
+    background: var(--bg);
+    color: var(--fg);
+    border: 1px solid var(--border);
+    border-radius: 4px;
+    padding: 0.25rem 0.5rem;
+}
+
+.role-form button {
+    font-family: inherit;
+    font-size: 0.85rem;
+    background: var(--bg-tertiary);
+    color: var(--fg);
+    border: 1px solid var(--border);
+    border-radius: 4px;
+    padding: 0.25rem 0.75rem;
+    cursor: pointer;
+}
+
+.role-form button:hover {
+    background: var(--border);
+    border-color: var(--fg-subtle);
+}
+
+.board-list {
+    width: 100%;
+    border-collapse: collapse;
+}
+
+.board-list th {
+    text-align: left;
+    font-size: 0.85rem;
+    color: var(--fg-subtle);
+}
+
+.board-list td {
+    padding: 0.3rem 0.4rem 0.3rem 0;
+    vertical-align: middle;
+}
+
+.board-list input[type="text"],
+.board-list input[type="number"] {
+    width: 100%;
+    font-family: inherit;
+    font-size: 0.9rem;
+    background: var(--bg);
+    color: var(--fg);
+    border: 1px solid var(--border);
+    border-radius: 4px;
+    padding: 0.25rem 0.5rem;
+}
+
+.board-list input.pos {
+    width: 3.5rem;
+}
+
+.board-list input:focus {
+    outline: none;
+    border-color: var(--fg-subtle);
+}
+
+.board-actions {
+    display: flex;
+    gap: 0.5rem;
+    align-items: center;
+}
+
+.board-actions button {
+    font-family: inherit;
+    font-size: 0.85rem;
+    background: var(--bg-tertiary);
+    color: var(--fg);
+    border: 1px solid var(--border);
+    border-radius: 4px;
+    padding: 0.25rem 0.75rem;
+    cursor: pointer;
+}
+
+.board-actions button:hover {
+    background: var(--border);
+    border-color: var(--fg-subtle);
+}
+
+.board-actions button.danger {
+    color: var(--danger, #e06c75);
+}
+
+.board-actions button:disabled {
+    opacity: 0.4;
+    cursor: not-allowed;
+}
+
+.post-tools {
+    display: flex;
+    align-items: center;
+    gap: 0.75rem;
+    margin-top: -0.1rem;
+}
+
+.upload-btn {
+    color: var(--fg-muted);
+    font-size: 0.85rem;
+    border: 1px solid var(--border);
+    border-radius: 4px;
+    padding: 0.2rem 0.6rem;
+}
+
+.upload-btn:hover {
+    color: var(--fg);
+    border-color: var(--fg-subtle);
+    text-decoration: none;
+}
+
+.upload-status {
+    color: var(--red);
+    font-size: 0.8rem;
+}
+
+textarea.drag-over {
+    border-color: var(--ml-accent);
+    background: var(--bg-secondary);
+}
+
+.upload-result {
+    margin-bottom: 1.5rem;
+    padding: 1rem;
+    background: var(--bg-secondary);
+    border: 1px solid var(--border);
+    border-radius: 4px;
+}
+
+.upload-result input {
+    width: 100%;
+    font-family: inherit;
+    background: var(--bg);
+    color: var(--fg);
+    border: 1px solid var(--border);
+    border-radius: 4px;
+    padding: 0.5rem 0.75rem;
+    margin: 0.5rem 0;
+}
+
+.upload-preview img {
+    max-width: 100%;
+    max-height: 320px;
+    border: 1px solid var(--border);
+    border-radius: 4px;
+}
+
+.vim-toggle {
+    display: flex;
+    align-items: center;
+    gap: 0.4rem;
+    margin-left: auto;
+    color: var(--fg-subtle);
+    font-size: 0.85rem;
+    cursor: pointer;
+}
+
+.vim-toggle input {
+    width: auto;
+    accent-color: var(--ml-accent);
+}
+
+.vim-status {
+    font-size: 0.85rem;
+    color: var(--fg-subtle);
+    margin-top: 0.25rem;
+    min-height: 1.2em;
+}
+
+textarea.vim-on {
+    caret-color: var(--orange);
+}
+
+textarea.vim-normal,
+textarea.vim-visual {
+    border-color: var(--ml-accent);
+}
+
+textarea.vim-insert {
+    border-color: var(--green);
+}
diff --git a/public/fonts/IosevkaNerdFontMono-Bold.ttf b/public/fonts/IosevkaNerdFontMono-Bold.ttf
new file mode 100644
index 0000000..9a8909e
Binary files /dev/null and b/public/fonts/IosevkaNerdFontMono-Bold.ttf differ
diff --git a/public/fonts/IosevkaNerdFontMono-Italic.ttf b/public/fonts/IosevkaNerdFontMono-Italic.ttf
new file mode 100644
index 0000000..b0597b2
Binary files /dev/null and b/public/fonts/IosevkaNerdFontMono-Italic.ttf differ
diff --git a/public/fonts/IosevkaNerdFontMono-Regular.ttf b/public/fonts/IosevkaNerdFontMono-Regular.ttf
new file mode 100644
index 0000000..a41104c
Binary files /dev/null and b/public/fonts/IosevkaNerdFontMono-Regular.ttf differ
diff --git a/public/index.php b/public/index.php
new file mode 100644
index 0000000..6bd29c2
--- /dev/null
+++ b/public/index.php
@@ -0,0 +1,27 @@
+<?php
+
+require dirname(__DIR__) . '/config/paths.php';
+require PATH_TO_CONFIG_DIR . 'init.php';
+
+global $route, $params;
+
+$routes = require PATH_TO_CONFIG_DIR . 'routes.php';
+
+$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
+$uri = rawurldecode(rtrim($uri, '/')) ?: '/';
+$method = $_SERVER['REQUEST_METHOD'];
+
+foreach ($routes as $pattern => $action) {
+    [$route_method, $route_pattern] = explode(' ', $pattern, 2);
+    if ($method !== $route_method) {
+        continue;
+    }
+    if (preg_match('#^' . $route_pattern . '$#', $uri, $matches)) {
+        $route = ['pattern' => $pattern, 'action' => $action];
+        $params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
+        $action($params);
+        exit;
+    }
+}
+
+Render::render_error(Error_Type::NOT_FOUND);
diff --git a/public/js/telescope.js b/public/js/telescope.js
new file mode 100644
index 0000000..9422347
--- /dev/null
+++ b/public/js/telescope.js
@@ -0,0 +1,299 @@
+'use strict';
+
+/**
+ * this is the telescope-style fuzzy finder modal. it owns the overlay, the
+ * result list + preview pane, and the current selection. the pure helpers
+ * (clamp_index, result_url, is_editable) hang off the class as static methods
+ */
+export class Telescope {
+
+    /**
+     * this function wraps an index into [0, len), so moving past either end
+     * of the result list loops around
+     *
+     * @param {number} i
+     * @param {number} len
+     * @return {number}
+     */
+    static clamp_index(i, len) {
+        if (len <= 0) {
+            return 0;
+        }
+        if (i < 0) {
+            return len - 1;
+        }
+        if (i >= len) {
+            return 0;
+        }
+        return i;
+    }
+
+    /**
+     * this function builds the thread url for a search result
+     *
+     * @param {object} result
+     * @return {string}
+     */
+    static result_url(result) {
+        return '/thread/' + encodeURIComponent(result.thread_id);
+    }
+
+    /**
+     * this function tells whether an element is something youre typing into,
+     * so the "/" shortcut doesnt hijack a real input
+     *
+     * @param {Element} el
+     * @return {boolean}
+     */
+    static is_editable(el) {
+        if (!el) {
+            return false;
+        }
+        const tag = el.tagName;
+        return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable === true;
+    }
+
+    constructor() {
+        this.results = [];
+        this.selected = 0;
+        this.open = false;
+        this.timer = null;
+        this.controller = null;
+        this.build();
+    }
+
+    /**
+     * this function builds the modal dom once and wires up its input + click
+     * handlers, then parks it on the body hidden
+     *
+     * @return {void}
+     */
+    build() {
+        const overlay = document.createElement('div');
+        overlay.className = 'tele-overlay';
+        overlay.innerHTML =
+            '<div class="tele">' +
+            '<input class="tele-input" type="text" placeholder="Search threads and posts..." autocomplete="off" spellcheck="false">' +
+            '<div class="tele-body"><ul class="tele-list"></ul><div class="tele-preview"></div></div>' +
+            '<div class="tele-footer"><span>&#x2191;&#x2193; move</span><span>&#x23ce; open</span><span>esc close</span></div>' +
+            '</div>';
+
+        this.overlay = overlay;
+        this.input = overlay.querySelector('.tele-input');
+        this.list = overlay.querySelector('.tele-list');
+        this.preview = overlay.querySelector('.tele-preview');
+
+        overlay.addEventListener('mousedown', (e) => {
+            if (e.target === overlay) {
+                this.close();
+            }
+        });
+        this.input.addEventListener('input', () => {
+            this.query(this.input.value);
+        });
+        this.input.addEventListener('keydown', (e) => {
+            this.on_key(e);
+        });
+
+        document.body.appendChild(overlay);
+    }
+
+    /**
+     * this function opens the modal, clearing whatever was there last and
+     * focusing the input
+     *
+     * @return {void}
+     */
+    show() {
+        this.open = true;
+        this.overlay.classList.add('tele-visible');
+        this.input.value = '';
+        this.results = [];
+        this.selected = 0;
+        this.render();
+        this.input.focus();
+    }
+
+    /**
+     * this function closes the modal and aborts any in-flight search
+     *
+     * @return {void}
+     */
+    close() {
+        this.open = false;
+        this.overlay.classList.remove('tele-visible');
+        if (this.controller) {
+            this.controller.abort();
+            this.controller = null;
+        }
+    }
+
+    /**
+     * this function handles keys while the modal is focused: esc closes,
+     * enter opens the selection, and arrows / ctrl-j / ctrl-k move the cursor
+     *
+     * @param {KeyboardEvent} e
+     * @return {void}
+     */
+    on_key(e) {
+        if (e.key === 'Escape') {
+            e.preventDefault();
+            this.close();
+        } else if (e.key === 'Enter') {
+            e.preventDefault();
+            this.choose();
+        } else if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'j')) {
+            e.preventDefault();
+            this.move(1);
+        } else if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'k')) {
+            e.preventDefault();
+            this.move(-1);
+        }
+    }
+
+    /**
+     * this function moves the selection by delta (wrapping at the ends)
+     *
+     * @param {number} delta
+     * @return {void}
+     */
+    move(delta) {
+        this.selected = Telescope.clamp_index(this.selected + delta, this.results.length);
+        this.render();
+    }
+
+    /**
+     * this function navigates to the currently selected result
+     *
+     * @return {void}
+     */
+    choose() {
+        const r = this.results[this.selected];
+        if (r) {
+            window.location.href = Telescope.result_url(r);
+        }
+    }
+
+    /**
+     * this function debounces typing so we only hit the server once the user
+     * pauses for a moment
+     *
+     * @param {string} q
+     * @return {void}
+     */
+    query(q) {
+        if (this.timer) {
+            clearTimeout(this.timer);
+        }
+        this.timer = setTimeout(() => this.fetch(q), 120);
+    }
+
+    /**
+     * this function fetches results from the /search json endpoint,
+     * cancelling any earlier request thats still in flight so stale results
+     * cant land late
+     *
+     * @param {string} q
+     * @return {void}
+     */
+    fetch(q) {
+        if (q.trim() === '') {
+            this.results = [];
+            this.selected = 0;
+            this.render();
+            return;
+        }
+        if (this.controller) {
+            this.controller.abort();
+        }
+        this.controller = new AbortController();
+        window.fetch('/search?json=1&q=' + encodeURIComponent(q), { signal: this.controller.signal })
+            .then((r) => r.json())
+            .then((data) => {
+                this.results = data || [];
+                this.selected = 0;
+                this.render();
+            })
+            .catch(() => {});
+    }
+
+    /**
+     * this function repaints the result list and the preview pane for the
+     * current selection. titles go in as text, but the snippet is set as html
+     * since the server already escaped it and only left <mark> tags in
+     *
+     * @return {void}
+     */
+    render() {
+        this.list.innerHTML = '';
+
+        if (!this.results.length) {
+            this.preview.innerHTML = '';
+            const empty = document.createElement('li');
+            empty.className = 'tele-empty';
+            empty.textContent = this.input.value.trim() === '' ? 'Type to search' : 'No results';
+            this.list.appendChild(empty);
+            return;
+        }
+
+        this.results.forEach((r, i) => {
+            const li = document.createElement('li');
+            li.className = 'tele-item' + (i === this.selected ? ' tele-active' : '');
+            const title = document.createElement('span');
+            title.className = 'tele-item-title';
+            title.textContent = r.title;
+            li.appendChild(title);
+            if (r.board_slug) {
+                const board = document.createElement('span');
+                board.className = 'tele-item-board';
+                board.textContent = r.board_slug;
+                li.appendChild(board);
+            }
+            li.addEventListener('mouseenter', () => {
+                this.selected = i;
+                this.render();
+            });
+            li.addEventListener('click', () => this.choose());
+            this.list.appendChild(li);
+        });
+
+        const sel = this.results[this.selected];
+        this.preview.innerHTML =
+            '<div class="tele-prev-title"></div><div class="tele-prev-snippet"></div>';
+        this.preview.querySelector('.tele-prev-title').textContent = sel.title;
+        this.preview.querySelector('.tele-prev-snippet').innerHTML = sel.snippet || '';
+    }
+}
+
+/**
+ * this function wires up the global "/" and ctrl-k shortcuts (ignored while
+ * youre typing in a field) plus any [data-telescope] links
+ *
+ * @return {void}
+ */
+function telescope_init() {
+    const tele = new Telescope();
+    document.addEventListener('keydown', (e) => {
+        if (tele.open) {
+            return;
+        }
+        if (e.key === '/' && !Telescope.is_editable(e.target)) {
+            e.preventDefault();
+            tele.show();
+        } else if (e.ctrlKey && e.key === 'k') {
+            e.preventDefault();
+            tele.show();
+        }
+    });
+    const links = document.querySelectorAll('[data-telescope]');
+    links.forEach((a) => {
+        a.addEventListener('click', (e) => {
+            e.preventDefault();
+            tele.show();
+        });
+    });
+}
+
+if (typeof document !== 'undefined' && document.querySelectorAll) {
+    telescope_init();
+}
diff --git a/public/js/upload.js b/public/js/upload.js
new file mode 100644
index 0000000..81d3af3
--- /dev/null
+++ b/public/js/upload.js
@@ -0,0 +1,189 @@
+'use strict';
+
+let seq = 0;
+
+/**
+ * this function digs the csrf token out of the form the textarea belongs to
+ *
+ * @param {HTMLFormElement} form
+ * @return {string}
+ */
+export function csrf_for(form) {
+    const input = form ? form.querySelector('input[name="csrf"]') : null;
+    return input ? input.value : '';
+}
+
+/**
+ * this function inserts text at the textarea's caret (replacing any
+ * selection) and fires an input event so vim mode / anything else resyncs
+ *
+ * @param {HTMLTextAreaElement} ta
+ * @param {string} text
+ * @return {void}
+ */
+export function insert_at_cursor(ta, text) {
+    const start = ta.selectionStart;
+    const end = ta.selectionEnd;
+    if (typeof ta.setRangeText === 'function') {
+        ta.setRangeText(text, start, end, 'end');
+    } else {
+        ta.value = ta.value.slice(0, start) + text + ta.value.slice(end);
+        ta.setSelectionRange(start + text.length, start + text.length);
+    }
+    ta.dispatchEvent(new Event('input', { bubbles: true }));
+}
+
+/**
+ * this function swaps the first occurrence of a placeholder token in the
+ * textarea for the final markdown (or for nothing if the upload failed)
+ *
+ * @param {HTMLTextAreaElement} ta
+ * @param {string} token
+ * @param {string} text
+ * @return {void}
+ */
+export function replace_token(ta, token, text) {
+    const i = ta.value.indexOf(token);
+    if (i === -1) {
+        return;
+    }
+    ta.value = ta.value.slice(0, i) + text + ta.value.slice(i + token.length);
+    ta.dispatchEvent(new Event('input', { bubbles: true }));
+}
+
+/**
+ * this function writes a message into the little status span next to the
+ * post box
+ *
+ * @param {HTMLTextAreaElement} ta
+ * @param {string} msg
+ * @return {void}
+ */
+export function set_status(ta, msg) {
+    const box = ta.parentNode.querySelector('.upload-status');
+    if (box) {
+        box.textContent = msg;
+    }
+}
+
+/**
+ * this function uploads one image file. it drops an "uploading…" placeholder
+ * in at the caret right away, posts the file to /upload, and then swaps the
+ * placeholder for the real ![](url) markdown (or clears it and shows the
+ * error on failure)
+ *
+ * @param {HTMLTextAreaElement} ta
+ * @param {File} file
+ * @return {void}
+ */
+export function upload_file(ta, file) {
+    if (!file || file.type.indexOf('image/') !== 0) {
+        return;
+    }
+    const token = '![uploading #' + (++seq) + '…]()';
+    insert_at_cursor(ta, token + '\n');
+
+    const data = new FormData();
+    data.append('csrf', csrf_for(ta.form));
+    data.append('image', file);
+
+    window.fetch('/upload?json=1', { method: 'POST', body: data })
+        .then((r) => r.json().then((j) => ({ ok: r.ok, body: j })))
+        .then((res) => {
+            if (res.ok && res.body.url) {
+                replace_token(ta, token, '![](' + res.body.url + ')');
+            } else {
+                replace_token(ta, token, '');
+                set_status(ta, res.body.error || 'Upload failed.');
+            }
+        })
+        .catch(() => {
+            replace_token(ta, token, '');
+            set_status(ta, 'Upload failed.');
+        });
+}
+
+/**
+ * this function hooks paste and drag-drop on a post box so dropping or
+ * pasting an image uploads it
+ *
+ * @param {HTMLTextAreaElement} ta
+ * @return {void}
+ */
+export function wire(ta) {
+    ta.addEventListener('paste', (e) => {
+        const items = e.clipboardData && e.clipboardData.items;
+        if (!items) {
+            return;
+        }
+        let handled = false;
+        for (let i = 0; i < items.length; i++) {
+            if (items[i].kind === 'file') {
+                const file = items[i].getAsFile();
+                if (file && file.type.indexOf('image/') === 0) {
+                    upload_file(ta, file);
+                    handled = true;
+                }
+            }
+        }
+        if (handled) {
+            e.preventDefault();
+        }
+    });
+
+    ta.addEventListener('dragover', (e) => {
+        e.preventDefault();
+        ta.classList.add('drag-over');
+    });
+    ta.addEventListener('dragleave', () => {
+        ta.classList.remove('drag-over');
+    });
+    ta.addEventListener('drop', (e) => {
+        e.preventDefault();
+        ta.classList.remove('drag-over');
+        const files = e.dataTransfer && e.dataTransfer.files;
+        if (!files) {
+            return;
+        }
+        for (let i = 0; i < files.length; i++) {
+            upload_file(ta, files[i]);
+        }
+    });
+}
+
+/**
+ * this function wires up the post boxes for upload: paste/drop on the
+ * textarea, plus the "attach image" link which falls back to the /upload
+ * page when js is off and otherwise pops the file picker
+ *
+ * @return {void}
+ */
+function upload_init() {
+    const areas = Array.prototype.slice.call(document.querySelectorAll('form .vim-area'));
+    areas.forEach(wire);
+
+    const buttons = Array.prototype.slice.call(document.querySelectorAll('[data-upload]'));
+    buttons.forEach((btn) => {
+        const form = btn.closest('form');
+        const ta = form ? form.querySelector('.vim-area') : null;
+        const picker = form ? form.querySelector('.upload-input') : null;
+        if (!ta || !picker) {
+            return;
+        }
+        btn.addEventListener('click', (e) => {
+            e.preventDefault();
+            picker.click();
+        });
+        picker.addEventListener('change', () => {
+            const files = picker.files;
+            for (let i = 0; i < files.length; i++) {
+                upload_file(ta, files[i]);
+            }
+            picker.value = '';
+        });
+    });
+}
+
+if (typeof document !== 'undefined' && document.querySelectorAll) {
+    upload_init();
+}
diff --git a/public/js/vim.js b/public/js/vim.js
new file mode 100644
index 0000000..b69bbfc
--- /dev/null
+++ b/public/js/vim.js
@@ -0,0 +1,890 @@
+'use strict';
+
+/**
+ * this is the little modal editor we bolt onto a textarea. it tracks its
+ * own caret + mode, draws a status line underneath, and translates key
+ * presses into vim-ish motions and edits on the textarea value. the pure
+ * text helpers (line_start, word_forward, ...) hang off the class as static
+ * methods since they take (string, position) and return a number with no
+ * instance state
+ */
+export class Vim_Editor {
+
+    /**
+     * this function returns the index of the start of the line that pos sits
+     * on
+     *
+     * @param {string} s
+     * @param {number} pos
+     * @return {number}
+     */
+    static line_start(s, pos) {
+        const i = s.lastIndexOf('\n', pos - 1);
+        return i === -1 ? 0 : i + 1;
+    }
+
+    /**
+     * this function returns the index of the end of the line pos sits on,
+     * i.e. the next newline or the end of the string
+     *
+     * @param {string} s
+     * @param {number} pos
+     * @return {number}
+     */
+    static line_end(s, pos) {
+        const i = s.indexOf('\n', pos);
+        return i === -1 ? s.length : i;
+    }
+
+    /**
+     * this function returns the index of the last real character on pos's
+     * line (where the block cursor can sit in normal mode)
+     *
+     * @param {string} s
+     * @param {number} pos
+     * @return {number}
+     */
+    static last_col(s, pos) {
+        const ls = Vim_Editor.line_start(s, pos);
+        const le = Vim_Editor.line_end(s, pos);
+        return le > ls ? le - 1 : ls;
+    }
+
+    /**
+     * this function returns the index of the first non-blank character on
+     * pos's line (for ^, I, gg, G)
+     *
+     * @param {string} s
+     * @param {number} pos
+     * @return {number}
+     */
+    static first_nonblank(s, pos) {
+        const ls = Vim_Editor.line_start(s, pos);
+        const le = Vim_Editor.line_end(s, pos);
+        let i = ls;
+        while (i < le && (s[i] === ' ' || s[i] === '\t')) {
+            i++;
+        }
+        return i < le ? i : ls;
+    }
+
+    /**
+     * this function buckets a character for word motions: 0 = whitespace,
+     * 1 = word char (\w), 2 = punctuation
+     *
+     * @param {string} ch
+     * @return {number}
+     */
+    static char_class(ch) {
+        if (ch === undefined || ch === ' ' || ch === '\t' || ch === '\n') {
+            return 0;
+        }
+        if (/[A-Za-z0-9_]/.test(ch)) {
+            return 1;
+        }
+        return 2;
+    }
+
+    /**
+     * this function returns the position of the next word start (like w)
+     *
+     * @param {string} s
+     * @param {number} pos
+     * @return {number}
+     */
+    static word_forward(s, pos) {
+        const n = s.length;
+        if (pos >= n) {
+            return n;
+        }
+        const c = Vim_Editor.char_class(s[pos]);
+        if (c !== 0) {
+            while (pos < n && Vim_Editor.char_class(s[pos]) === c) {
+                pos++;
+            }
+        }
+        while (pos < n && Vim_Editor.char_class(s[pos]) === 0) {
+            pos++;
+        }
+        return pos;
+    }
+
+    /**
+     * this function returns the position of the end of the current/next word
+     * (like e)
+     *
+     * @param {string} s
+     * @param {number} pos
+     * @return {number}
+     */
+    static word_end(s, pos) {
+        const n = s.length;
+        pos++;
+        while (pos < n && Vim_Editor.char_class(s[pos]) === 0) {
+            pos++;
+        }
+        if (pos >= n) {
+            return n - 1;
+        }
+        const c = Vim_Editor.char_class(s[pos]);
+        while (pos + 1 < n && Vim_Editor.char_class(s[pos + 1]) === c) {
+            pos++;
+        }
+        return pos;
+    }
+
+    /**
+     * this function returns the position of the previous word start (like b)
+     *
+     * @param {string} s
+     * @param {number} pos
+     * @return {number}
+     */
+    static word_back(s, pos) {
+        pos--;
+        while (pos > 0 && Vim_Editor.char_class(s[pos]) === 0) {
+            pos--;
+        }
+        if (pos <= 0) {
+            return 0;
+        }
+        const c = Vim_Editor.char_class(s[pos]);
+        while (pos - 1 >= 0 && Vim_Editor.char_class(s[pos - 1]) === c) {
+            pos--;
+        }
+        return pos;
+    }
+
+    constructor(textarea) {
+        this.ta = textarea;
+        this.mode = 'normal';
+        this.caret = textarea.selectionStart || 0;
+        this.count = '';
+        this.op = null;
+        this.prefix = null;
+        this.await_char = null;
+        this.anchor = 0;
+        this.reg = { text: '', linewise: false };
+        this.history = [];
+        this.cmd_active = false;
+        this.cmd = '';
+
+        this.status = document.createElement('div');
+        this.status.className = 'vim-status';
+        this.ta.insertAdjacentElement('afterend', this.status);
+
+        this.on_key = this.handle_key.bind(this);
+        this.on_click = this.handle_click.bind(this);
+        this.ta.addEventListener('keydown', this.on_key);
+        this.ta.addEventListener('mouseup', this.on_click);
+        this.ta.classList.add('vim-on');
+        this.render();
+    }
+
+    /**
+     * this function unhooks the editor from its textarea and removes the
+     * status line, putting the box back to a plain textarea
+     *
+     * @return {void}
+     */
+    detach() {
+        this.ta.removeEventListener('keydown', this.on_key);
+        this.ta.removeEventListener('mouseup', this.on_click);
+        this.ta.classList.remove('vim-on', 'vim-normal', 'vim-insert', 'vim-visual');
+        if (this.status && this.status.parentNode) {
+            this.status.parentNode.removeChild(this.status);
+        }
+    }
+
+    text() {
+        return this.ta.value;
+    }
+
+    /**
+     * this function pushes the current value + caret onto the undo stack
+     * (capped so it doesnt grow forever)
+     *
+     * @return {void}
+     */
+    snapshot() {
+        this.history.push({ v: this.ta.value, caret: this.caret });
+        if (this.history.length > 200) {
+            this.history.shift();
+        }
+    }
+
+    /**
+     * this function pops the last snapshot and restores it (u)
+     *
+     * @return {void}
+     */
+    undo() {
+        const prev = this.history.pop();
+        if (!prev) {
+            return;
+        }
+        this.ta.value = prev.v;
+        this.caret = Math.min(prev.caret, this.ta.value.length);
+    }
+
+    /**
+     * this function keeps the caret inside the current line in normal mode,
+     * so it never lands past the last character or on the newline
+     *
+     * @return {void}
+     */
+    clamp_normal() {
+        const s = this.text();
+        if (this.caret > s.length) {
+            this.caret = s.length;
+        }
+        const ls = Vim_Editor.line_start(s, this.caret);
+        const lc = Vim_Editor.last_col(s, this.caret);
+        if (this.caret > lc) {
+            this.caret = lc;
+        }
+        if (this.caret < ls) {
+            this.caret = ls;
+        }
+    }
+
+    /**
+     * this function redraws the status line for the current mode and paints
+     * the "cursor": a one-char selection in normal mode (so it reads like a
+     * block cursor) or the full range in visual mode
+     *
+     * @return {void}
+     */
+    render() {
+        const s = this.text();
+        this.ta.classList.remove('vim-normal', 'vim-insert', 'vim-visual');
+        this.ta.classList.add('vim-' + this.mode);
+
+        if (this.mode === 'insert') {
+            this.status.textContent = '-- INSERT --';
+            return;
+        }
+
+        if (this.cmd_active) {
+            this.status.textContent = ':' + this.cmd;
+        } else if (this.mode === 'visual') {
+            this.status.textContent = '-- VISUAL --';
+        } else {
+            this.status.textContent = 'NORMAL';
+        }
+
+        if (this.mode === 'visual') {
+            const lo = Math.min(this.anchor, this.caret);
+            const hi = Math.max(this.anchor, this.caret) + 1;
+            this.ta.setSelectionRange(lo, Math.min(hi, s.length));
+        } else {
+            const end = this.caret < s.length && s[this.caret] !== '\n' ? this.caret + 1 : this.caret;
+            this.ta.setSelectionRange(this.caret, end);
+        }
+    }
+
+    /**
+     * this function syncs our caret to wherever the mouse put it (so clicking
+     * around in normal mode behaves)
+     *
+     * @return {void}
+     */
+    handle_click() {
+        if (this.mode === 'insert') {
+            return;
+        }
+        this.caret = this.ta.selectionStart;
+        this.clamp_normal();
+        this.render();
+    }
+
+    /**
+     * this function inserts text at the current selection in insert mode
+     * (used for the tab key)
+     *
+     * @param {string} text
+     * @return {void}
+     */
+    insert_text(text) {
+        const ta = this.ta;
+        const start = ta.selectionStart;
+        const end = ta.selectionEnd;
+        ta.value = ta.value.slice(0, start) + text + ta.value.slice(end);
+        ta.setSelectionRange(start + text.length, start + text.length);
+    }
+
+    /**
+     * this function drops into insert mode at the given caret, snapshotting
+     * first so the whole insert can be undone in one go
+     *
+     * @param {number} caret
+     * @return {void}
+     */
+    to_insert(caret) {
+        this.snapshot();
+        this.mode = 'insert';
+        this.caret = caret;
+        this.ta.setSelectionRange(caret, caret);
+        this.render();
+    }
+
+    /**
+     * this function leaves insert mode back to normal, nudging the caret left
+     * one like vim does on escape
+     *
+     * @return {void}
+     */
+    to_normal() {
+        if (this.mode === 'insert') {
+            this.caret = this.ta.selectionStart;
+            const ls = Vim_Editor.line_start(this.text(), this.caret);
+            if (this.caret > ls) {
+                this.caret--;
+            }
+        }
+        this.mode = 'normal';
+        this.op = null;
+        this.prefix = null;
+        this.count = '';
+        this.clamp_normal();
+        this.render();
+    }
+
+    /**
+     * this function resolves a character motion (h l w b e j k) n times and
+     * returns the resulting index without moving the caret
+     *
+     * @param {string} key
+     * @param {number} n
+     * @return {number}
+     */
+    motion(key, n) {
+        const s = this.text();
+        let pos = this.caret;
+        for (let i = 0; i < n; i++) {
+            if (key === 'h') {
+                pos = Math.max(Vim_Editor.line_start(s, pos), pos - 1);
+            } else if (key === 'l') {
+                pos = Math.min(Vim_Editor.last_col(s, pos), pos + 1);
+            } else if (key === 'w') {
+                pos = Vim_Editor.word_forward(s, pos);
+            } else if (key === 'b') {
+                pos = Vim_Editor.word_back(s, pos);
+            } else if (key === 'e') {
+                pos = Vim_Editor.word_end(s, pos);
+            } else if (key === 'j' || key === 'k') {
+                const col = pos - Vim_Editor.line_start(s, pos);
+                if (key === 'j') {
+                    const nle = Vim_Editor.line_end(s, pos);
+                    if (nle >= s.length) {
+                        break;
+                    }
+                    const ns = nle + 1;
+                    pos = Math.min(ns + col, Vim_Editor.last_col(s, ns));
+                } else {
+                    const cls = Vim_Editor.line_start(s, pos);
+                    if (cls === 0) {
+                        break;
+                    }
+                    const ps = Vim_Editor.line_start(s, cls - 1);
+                    pos = Math.min(ps + col, Vim_Editor.last_col(s, ps));
+                }
+            }
+        }
+        return pos;
+    }
+
+    /**
+     * this function returns the [from, to) range covering n whole lines from
+     * the caret, used by linewise operators like dd / yy
+     *
+     * @param {number} n
+     * @return {{from: number, to: number}}
+     */
+    line_range(n) {
+        const s = this.text();
+        const from = Vim_Editor.line_start(s, this.caret);
+        let to = from;
+        for (let i = 0; i < n; i++) {
+            to = Vim_Editor.line_end(s, to);
+            if (to < s.length) {
+                to++;
+            }
+        }
+        return { from: from, to: to };
+    }
+
+    /**
+     * this function applies an operator (d/c/y) over a range. it yanks into
+     * the register, and for d/c it also deletes the text (c then drops into
+     * insert)
+     *
+     * @param {string} op
+     * @param {number} from
+     * @param {number} to
+     * @param {boolean} linewise
+     * @return {void}
+     */
+    apply_op(op, from, to, linewise) {
+        const s = this.text();
+        if (from > to) {
+            const t = from;
+            from = to;
+            to = t;
+        }
+        const chunk = s.slice(from, to);
+        if (op === 'y') {
+            this.reg = { text: chunk, linewise: linewise };
+            this.caret = from;
+            return;
+        }
+        this.snapshot();
+        this.reg = { text: chunk, linewise: linewise };
+        this.ta.value = s.slice(0, from) + s.slice(to);
+        this.caret = from;
+        if (op === 'c') {
+            if (linewise) {
+                this.ta.value = this.ta.value.slice(0, from) + '\n' + this.ta.value.slice(from);
+            }
+            this.mode = 'normal';
+            this.to_insert(from);
+            return;
+        }
+        this.clamp_normal();
+    }
+
+    /**
+     * this function pastes the register after (p) or before (P) the caret,
+     * handling linewise vs charwise registers the way vim does
+     *
+     * @param {boolean} after
+     * @return {void}
+     */
+    paste(after) {
+        const s = this.text();
+        this.snapshot();
+        if (this.reg.linewise) {
+            let insert_at;
+            if (after) {
+                insert_at = Vim_Editor.line_end(s, this.caret);
+                this.ta.value = s.slice(0, insert_at) + '\n' + this.reg.text.replace(/\n$/, '') + s.slice(insert_at);
+                this.caret = insert_at + 1;
+            } else {
+                insert_at = Vim_Editor.line_start(s, this.caret);
+                this.ta.value = s.slice(0, insert_at) + this.reg.text.replace(/\n$/, '') + '\n' + s.slice(insert_at);
+                this.caret = insert_at;
+            }
+        } else {
+            const at = after ? Math.min(this.caret + 1, s.length) : this.caret;
+            this.ta.value = s.slice(0, at) + this.reg.text + s.slice(at);
+            this.caret = at + this.reg.text.length - 1;
+        }
+        this.clamp_normal();
+    }
+
+    /**
+     * this function is the keydown handler. in insert mode it only intercepts
+     * escape and tab and lets everything else type through; otherwise it
+     * routes the key into dispatch (or the command line if one is open)
+     *
+     * @param {KeyboardEvent} e
+     * @return {void}
+     */
+    handle_key(e) {
+        if (this.cmd_active) {
+            this.handle_cmdline(e);
+            return;
+        }
+        if (this.mode === 'insert') {
+            if (e.key === 'Escape') {
+                e.preventDefault();
+                this.to_normal();
+            } else if (e.key === 'Tab') {
+                e.preventDefault();
+                this.insert_text('    ');
+            }
+            return;
+        }
+        if (e.ctrlKey || e.metaKey || e.altKey) {
+            return;
+        }
+        if (e.key === 'Shift' || e.key === 'CapsLock') {
+            return;
+        }
+        e.preventDefault();
+        this.dispatch(e.key);
+    }
+
+    /**
+     * this function is the normal/visual mode command interpreter. it handles
+     * pending operators, counts, the g prefix, r, motions, edits, and the
+     * mode switches
+     *
+     * @param {string} key
+     * @return {void}
+     */
+    dispatch(key) {
+        const s = this.text();
+
+        if (this.await_char) {
+            if (this.await_char === 'r' && key.length === 1) {
+                this.snapshot();
+                this.ta.value = s.slice(0, this.caret) + key + s.slice(this.caret + 1);
+            }
+            this.await_char = null;
+            this.render();
+            return;
+        }
+
+        if (this.prefix === 'g') {
+            this.prefix = null;
+            if (key === 'g') {
+                this.caret = Vim_Editor.first_nonblank(s, 0);
+            }
+            this.render();
+            return;
+        }
+
+        if (this.mode === 'visual' && 'dxyc'.indexOf(key) !== -1) {
+            this.handle_visual(key);
+            return;
+        }
+
+        if (/[1-9]/.test(key) || (key === '0' && this.count !== '')) {
+            this.count += key;
+            return;
+        }
+
+        const n = this.count === '' ? 1 : parseInt(this.count, 10);
+        this.count = '';
+
+        if (key === 'g') {
+            this.prefix = 'g';
+            return;
+        }
+
+        if (this.op) {
+            this.handle_operator_motion(key, n);
+            return;
+        }
+
+        if ('hjklwbe'.indexOf(key) !== -1) {
+            this.caret = this.motion(key, n);
+            this.clamp_normal_for(key);
+            this.render();
+            return;
+        }
+
+        switch (key) {
+            case '0':
+                this.caret = Vim_Editor.line_start(s, this.caret);
+                break;
+            case '^':
+                this.caret = Vim_Editor.first_nonblank(s, this.caret);
+                break;
+            case '$':
+                this.caret = Vim_Editor.last_col(s, this.caret);
+                break;
+            case 'G':
+                this.caret = Vim_Editor.first_nonblank(s, s.length);
+                break;
+            case 'i':
+                this.to_insert(this.caret);
+                return;
+            case 'a':
+                this.to_insert(Math.min(this.caret + 1, s.length));
+                return;
+            case 'I':
+                this.to_insert(Vim_Editor.first_nonblank(s, this.caret));
+                return;
+            case 'A':
+                this.to_insert(Vim_Editor.line_end(s, this.caret));
+                return;
+            case 'o':
+                this.open_line(true);
+                return;
+            case 'O':
+                this.open_line(false);
+                return;
+            case 'x':
+                this.delete_chars(n);
+                break;
+            case 'D':
+                this.apply_op('d', this.caret, Vim_Editor.line_end(s, this.caret), false);
+                break;
+            case 'C':
+                this.apply_op('c', this.caret, Vim_Editor.line_end(s, this.caret), false);
+                return;
+            case 'r':
+                this.await_char = 'r';
+                return;
+            case 'd':
+            case 'c':
+            case 'y':
+                this.op = key;
+                this.op_count = n;
+                return;
+            case 'p':
+                this.paste(true);
+                break;
+            case 'P':
+                this.paste(false);
+                break;
+            case 'u':
+                this.undo();
+                break;
+            case 'v':
+                this.mode = 'visual';
+                this.anchor = this.caret;
+                break;
+            case ':':
+                this.cmd_active = true;
+                this.cmd = '';
+                this.render();
+                return;
+            case 'Escape':
+                if (this.mode === 'visual') {
+                    this.mode = 'normal';
+                }
+                break;
+            default:
+                if (this.mode === 'visual') {
+                    this.handle_visual(key);
+                    return;
+                }
+                this.render();
+                return;
+        }
+
+        if (this.mode === 'visual' && 'hjklwbe0^$G'.indexOf(key) !== -1) {
+            this.render();
+            return;
+        }
+        this.clamp_normal();
+        this.render();
+    }
+
+    clamp_normal_for(key) {
+        if (this.mode === 'visual') {
+            const s = this.text();
+            if (this.caret > s.length) {
+                this.caret = s.length;
+            }
+            return;
+        }
+        this.clamp_normal();
+    }
+
+    /**
+     * this function handles d/x/y/c while in visual mode: it operates over
+     * the current selection then drops back to normal mode
+     *
+     * @param {string} key
+     * @return {void}
+     */
+    handle_visual(key) {
+        const s = this.text();
+        if (key === 'd' || key === 'x' || key === 'y' || key === 'c') {
+            const lo = Math.min(this.anchor, this.caret);
+            const hi = Math.max(this.anchor, this.caret) + 1;
+            const op = key === 'x' ? 'd' : key;
+            this.mode = 'normal';
+            this.apply_op(op, lo, Math.min(hi, s.length), false);
+            if (op !== 'c') {
+                this.render();
+            }
+            return;
+        }
+        this.render();
+    }
+
+    /**
+     * this function completes an operator that was waiting on a motion (dd,
+     * dw, cw, d$, yy, ...) and applies it over the resulting range
+     *
+     * @param {string} key
+     * @param {number} n
+     * @return {void}
+     */
+    handle_operator_motion(key, n) {
+        const s = this.text();
+        const op = this.op;
+        this.op = null;
+        const nn = (this.op_count || 1) * n;
+
+        if (key === op || (op === 'd' && key === 'd') || (op === 'c' && key === 'c') || (op === 'y' && key === 'y')) {
+            const lr = this.line_range(nn);
+            this.apply_op(op, lr.from, lr.to, true);
+            this.render();
+            return;
+        }
+
+        const from = this.caret;
+        let to = this.caret;
+        if ('wbe'.indexOf(key) !== -1) {
+            to = this.motion(key, nn);
+            if (key === 'e') {
+                to += 1;
+            }
+        } else if (key === 'l' || key === 'h') {
+            to = this.motion(key, nn);
+        } else if (key === '$') {
+            to = Vim_Editor.line_end(s, this.caret);
+        } else if (key === '0') {
+            to = Vim_Editor.line_start(s, this.caret);
+        } else {
+            this.render();
+            return;
+        }
+        this.apply_op(op, from, to, false);
+        this.render();
+    }
+
+    /**
+     * this function deletes n characters from the caret (x), stopping at the
+     * end of the line
+     *
+     * @param {number} n
+     * @return {void}
+     */
+    delete_chars(n) {
+        const s = this.text();
+        const le = Vim_Editor.line_end(s, this.caret);
+        const to = Math.min(this.caret + n, le);
+        if (to <= this.caret) {
+            return;
+        }
+        this.apply_op('d', this.caret, to, false);
+    }
+
+    /**
+     * this function opens a new line below (o) or above (O) and drops into
+     * insert mode on it
+     *
+     * @param {boolean} below
+     * @return {void}
+     */
+    open_line(below) {
+        const s = this.text();
+        this.snapshot();
+        let at;
+        if (below) {
+            at = Vim_Editor.line_end(s, this.caret);
+            this.ta.value = s.slice(0, at) + '\n' + s.slice(at);
+            this.mode = 'normal';
+            this.to_insert(at + 1);
+        } else {
+            at = Vim_Editor.line_start(s, this.caret);
+            this.ta.value = s.slice(0, at) + '\n' + s.slice(at);
+            this.mode = 'normal';
+            this.to_insert(at);
+        }
+    }
+
+    /**
+     * this function feeds keystrokes into the ":" command line and runs it on
+     * enter (escape cancels, backspace edits)
+     *
+     * @param {KeyboardEvent} e
+     * @return {void}
+     */
+    handle_cmdline(e) {
+        e.preventDefault();
+        if (e.key === 'Escape') {
+            this.cmd_active = false;
+            this.cmd = '';
+            this.render();
+            return;
+        }
+        if (e.key === 'Enter') {
+            this.cmd_active = false;
+            this.run_command(this.cmd);
+            this.cmd = '';
+            this.render();
+            return;
+        }
+        if (e.key === 'Backspace') {
+            this.cmd = this.cmd.slice(0, -1);
+            this.render();
+            return;
+        }
+        if (e.key.length === 1) {
+            this.cmd += e.key;
+            this.render();
+        }
+    }
+
+    /**
+     * this function runs an ex command. only the "write" variants are wired
+     * up, and they submit the post form via its primary button
+     *
+     * @param {string} cmd
+     * @return {void}
+     */
+    run_command(cmd) {
+        cmd = cmd.trim();
+        if (cmd === 'w' || cmd === 'wq' || cmd === 'x' || cmd === 'wq!') {
+            const form = this.ta.form;
+            if (!form) {
+                return;
+            }
+            const primary = form.querySelector('button[value="create"], button[value="post"]');
+            if (primary && form.requestSubmit) {
+                form.requestSubmit(primary);
+            } else {
+                form.submit();
+            }
+        }
+    }
+}
+
+/**
+ * this function wires up the page: it reads the saved preference, syncs the
+ * "vim" checkboxes, and attaches/detaches editors on the post boxes when the
+ * toggle changes. its progressive enhancement, so with js off the boxes are
+ * just plain textareas
+ *
+ * @return {void}
+ */
+function vim_init() {
+    let pref = false;
+    try {
+        pref = localStorage.getItem('forum_vim') === '1';
+    } catch (e) {}
+
+    const toggles = Array.prototype.slice.call(document.querySelectorAll('.vim-toggle input'));
+    let editors = [];
+
+    function targets() {
+        return Array.prototype.slice.call(document.querySelectorAll('form .vim-area'));
+    }
+    function enable() {
+        editors = targets().map((t) => new Vim_Editor(t));
+    }
+    function disable() {
+        editors.forEach((ed) => ed.detach());
+        editors = [];
+    }
+
+    toggles.forEach((cb) => {
+        cb.checked = pref;
+        cb.addEventListener('change', () => {
+            toggles.forEach((t) => { t.checked = cb.checked; });
+            try {
+                localStorage.setItem('forum_vim', cb.checked ? '1' : '0');
+            } catch (e) {}
+            disable();
+            if (cb.checked) {
+                enable();
+            }
+        });
+    });
+
+    if (pref) {
+        enable();
+    }
+}
+
+if (typeof document !== 'undefined' && document.querySelectorAll) {
+    vim_init();
+}
diff --git a/public/uploads/.gitignore b/public/uploads/.gitignore
new file mode 100644
index 0000000..e24a60f
--- /dev/null
+++ b/public/uploads/.gitignore
@@ -0,0 +1,3 @@
+*
+!.gitignore
+!.htaccess
diff --git a/public/uploads/.htaccess b/public/uploads/.htaccess
new file mode 100644
index 0000000..494c70f
--- /dev/null
+++ b/public/uploads/.htaccess
@@ -0,0 +1,9 @@
+php_flag engine off
+RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .phps
+RemoveType .php .phtml .phps
+
+<FilesMatch "\.(?i:php|phtml|phps|php[0-9])$">
+    Require all denied
+</FilesMatch>
+
+Header set X-Content-Type-Options "nosniff"
diff --git a/types/Error_Type.php b/types/Error_Type.php
new file mode 100644
index 0000000..26f3253
--- /dev/null
+++ b/types/Error_Type.php
@@ -0,0 +1,29 @@
+<?php
+
+enum Error_Type : int {
+    case BAD_REQUEST  = 400;
+    case UNAUTHORIZED = 401;
+    case FORBIDDEN    = 403;
+    case NOT_FOUND    = 404;
+    case CONFLICT     = 409;
+    case INVALID_CSRF = 419;
+    case INTERNAL     = 500;
+
+    /**
+     * this function returns the human title for this error, used as the page
+     * heading and the response body. keep these short — they appear verbatim
+     *
+     * @return string
+     */
+    public function message() : string {
+        return match ($this) {
+            self::BAD_REQUEST  => '400 Bad Request',
+            self::UNAUTHORIZED => '401 Unauthorized',
+            self::FORBIDDEN    => '403 Forbidden',
+            self::NOT_FOUND    => '404 Not Found',
+            self::CONFLICT     => '409 Conflict',
+            self::INVALID_CSRF => 'Invalid request token',
+            self::INTERNAL     => '500 Internal Server Error',
+        };
+    }
+}
diff --git a/types/Role.php b/types/Role.php
new file mode 100644
index 0000000..39a5ada
--- /dev/null
+++ b/types/Role.php
@@ -0,0 +1,38 @@
+<?php
+
+enum Role: string {
+    case Admin  = 'admin';
+    case Mod    = 'mod';
+    case Normal = 'normal';
+
+    /**
+     * this function says whether the role can moderate (mods and admins)
+     *
+     * @return bool
+     */
+    public function is_mod() : bool {
+        return $this === self::Admin || $this === self::Mod;
+    }
+
+    /**
+     * this function says whether the role is an admin
+     *
+     * @return bool
+     */
+    public function is_admin() : bool {
+        return $this === self::Admin;
+    }
+
+    /**
+     * this function returns the little badge span for a role (empty for normal)
+     *
+     * @return string
+     */
+    public function badge() : string {
+        return match ($this) {
+            self::Admin  => '<span class="role-badge role-admin">admin</span>',
+            self::Mod    => '<span class="role-badge role-mod">mod</span>',
+            self::Normal => '',
+        };
+    }
+}
diff --git a/views/Admin_View.php b/views/Admin_View.php
new file mode 100644
index 0000000..0c257fe
--- /dev/null
+++ b/views/Admin_View.php
@@ -0,0 +1,106 @@
+<?php $title = "Admin — Tony's Forums"; ?>
+<?php include PATH_TO_VIEWS_DIR . 'Header_View.php'; ?>
+
+<div class="breadcrumb"><a href="/">forums</a> / admin</div>
+
+<h1>Users</h1>
+
+<table class="user-list">
+    <thead>
+        <tr>
+            <th>User</th>
+            <th>Role</th>
+            <th>Joined</th>
+            <th>Threads</th>
+            <th>Posts</th>
+            <th>Set role</th>
+        </tr>
+    </thead>
+    <tbody>
+        <?php foreach ($users as $u): ?>
+        <tr>
+            <td><a href="/u/<?= esc($u['username']) ?>"><?= esc($u['username']) ?></a></td>
+            <td><?= role_badge($u['role']) ?: '<span class="muted">normal</span>' ?></td>
+            <td class="muted"><?= esc(date('Y-m-d', (int) $u['created_at'])) ?></td>
+            <td><?= (int) $u['thread_count'] ?></td>
+            <td><?= (int) $u['post_count'] ?></td>
+            <td>
+                <?php if ((int) $u['id'] === (int) $current_user['id']): ?>
+                    <span class="muted">(you)</span>
+                <?php else: ?>
+                <form method="post" action="/admin/user/<?= (int) $u['id'] ?>/role" class="role-form">
+                    <input type="hidden" name="csrf" value="<?= esc(Session::csrf_token()) ?>">
+                    <select name="role">
+                        <?php foreach (['normal', 'mod', 'admin'] as $r): ?>
+                        <option value="<?= $r ?>" <?= $u['role'] === $r ? 'selected' : '' ?>><?= $r ?></option>
+                        <?php endforeach; ?>
+                    </select>
+                    <button type="submit">set</button>
+                </form>
+                <?php endif; ?>
+            </td>
+        </tr>
+        <?php endforeach; ?>
+    </tbody>
+</table>
+
+<h1>Boards</h1>
+
+<table class="board-list">
+    <thead>
+        <tr>
+            <th>Slug</th>
+            <th>Name</th>
+            <th>Description</th>
+            <th>Category</th>
+            <th>Pos</th>
+            <th>Threads</th>
+            <th></th>
+        </tr>
+    </thead>
+    <tbody>
+        <?php foreach ($boards as $b): ?>
+        <tr>
+            <td><input form="board-<?= (int) $b['id'] ?>" type="text" name="slug" value="<?= esc($b['slug']) ?>" pattern="[a-z0-9-]+" required></td>
+            <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><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">
+                <form id="board-<?= (int) $b['id'] ?>" method="post" action="/admin/board/<?= (int) $b['id'] ?>">
+                    <input type="hidden" name="csrf" value="<?= esc(Session::csrf_token()) ?>">
+                    <button type="submit">save</button>
+                </form>
+                <form method="post" action="/admin/board/<?= (int) $b['id'] ?>/delete" onsubmit="return confirm('Delete board &quot;<?= esc($b['slug']) ?>&quot;?');">
+                    <input type="hidden" name="csrf" value="<?= esc(Session::csrf_token()) ?>">
+                    <button type="submit" class="danger" <?= (int) $b['thread_count'] > 0 ? 'disabled title="board still has threads"' : '' ?>>delete</button>
+                </form>
+            </td>
+        </tr>
+        <?php endforeach; ?>
+    </tbody>
+    <tfoot>
+        <tr>
+            <td><input form="board-new" type="text" name="slug" placeholder="slug" pattern="[a-z0-9-]+" required></td>
+            <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><input form="board-new" type="number" name="position" value="<?= count($boards) ?>" class="pos"></td>
+            <td class="muted">—</td>
+            <td class="board-actions">
+                <form id="board-new" method="post" action="/admin/board">
+                    <input type="hidden" name="csrf" value="<?= esc(Session::csrf_token()) ?>">
+                    <button type="submit">add</button>
+                </form>
+            </td>
+        </tr>
+    </tfoot>
+</table>
+
+<?php
+$ml_buffer = 'admin';
+$ml_type = 'admin';
+$ml_info = count($users) . ' users · ' . count($boards) . ' boards';
+include PATH_TO_VIEWS_DIR . 'Footer_View.php';
+?>
diff --git a/views/Board_View.php b/views/Board_View.php
new file mode 100644
index 0000000..9f236eb
--- /dev/null
+++ b/views/Board_View.php
@@ -0,0 +1,61 @@
+<?php $title = $board['name'] . " — Tony's Forums"; ?>
+<?php include PATH_TO_VIEWS_DIR . 'Header_View.php'; ?>
+
+<div class="breadcrumb"><a href="/">forums</a> / <?= esc($board['name']) ?></div>
+
+<div class="board-header">
+    <h1><?= esc($board['name']) ?></h1>
+    <p class="board-desc"><?= esc($board['description']) ?></p>
+    <a href="/board/<?= esc($board['slug']) ?>/new" class="button-link">New thread</a>
+</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>
+<?php else: ?>
+<table class="thread-list">
+    <thead>
+        <tr>
+            <th>Title</th>
+            <th>Author</th>
+            <th>Replies</th>
+            <th>Activity</th>
+        </tr>
+    </thead>
+    <tbody>
+        <?php foreach ($threads as $t): ?>
+        <tr>
+            <td class="title">
+                <?php if ($t['pinned']): ?><span class="badge badge-pin">pinned</span><?php endif; ?>
+                <?php if ($t['locked']): ?><span class="badge badge-lock">locked</span><?php endif; ?>
+                <a href="/thread/<?= (int) $t['id'] ?>"><?= esc($t['title']) ?></a>
+            </td>
+            <td class="author"><a href="/u/<?= esc($t['username']) ?>"><?= esc($t['username']) ?></a></td>
+            <td class="replies"><?= max(0, (int) $t['post_count'] - 1) ?></td>
+            <td class="activity"><?= esc(time_ago((int) $t['updated_at'])) ?></td>
+        </tr>
+        <?php endforeach; ?>
+    </tbody>
+</table>
+
+<?php
+$pages = (int) ceil($total / $per_page);
+if ($pages > 1):
+?>
+<div class="pagination">
+    <?php if ($page > 1): ?>
+        <a href="/board/<?= esc($board['slug']) ?>?page=<?= $page - 1 ?>">&larr; newer</a>
+    <?php else: ?><span></span><?php endif; ?>
+    <span class="page-info">page <?= $page ?> of <?= $pages ?></span>
+    <?php if ($page < $pages): ?>
+        <a href="/board/<?= esc($board['slug']) ?>?page=<?= $page + 1 ?>">older &rarr;</a>
+    <?php else: ?><span></span><?php endif; ?>
+</div>
+<?php endif; ?>
+<?php endif; ?>
+
+<?php
+$ml_buffer = 'board/' . $board['slug'];
+$ml_type = 'board';
+$ml_info = $total . ' threads';
+include PATH_TO_VIEWS_DIR . 'Footer_View.php';
+?>
diff --git a/views/Error_View.php b/views/Error_View.php
new file mode 100644
index 0000000..a712328
--- /dev/null
+++ b/views/Error_View.php
@@ -0,0 +1,9 @@
+<?php $title = 'Error'; ?>
+<?php include PATH_TO_VIEWS_DIR . 'Header_View.php'; ?>
+
+<div class="error">
+    <h1><?= esc($error) ?></h1>
+    <p><a href="/">Back to threads</a></p>
+</div>
+
+<?php include PATH_TO_VIEWS_DIR . 'Footer_View.php'; ?>
diff --git a/views/Errors_View.php b/views/Errors_View.php
new file mode 100644
index 0000000..332e331
--- /dev/null
+++ b/views/Errors_View.php
@@ -0,0 +1,7 @@
+<?php if (!empty($errors)): ?>
+<ul class="form-errors">
+    <?php foreach ($errors as $err): ?>
+    <li><?= esc($err) ?></li>
+    <?php endforeach; ?>
+</ul>
+<?php endif; ?>
diff --git a/views/Footer_View.php b/views/Footer_View.php
new file mode 100644
index 0000000..ba5402c
--- /dev/null
+++ b/views/Footer_View.php
@@ -0,0 +1,34 @@
+<?php
+$ml_mode   = $ml_mode   ?? 'NORMAL';
+$ml_branch = $ml_branch ?? 'main';
+$ml_buffer = $ml_buffer ?? 'forums';
+$ml_type   = $ml_type   ?? 'forum';
+$ml_info   = $ml_info   ?? '';
+?>
+</main>
+<footer class="modeline">
+    <div class="ml-row">
+        <span class="ml-a ml-mode"><?= esc($ml_mode) ?></span>
+        <span class="ml-sep ml-fwd sep-a-b"></span>
+        <span class="ml-b">&#xe0a0; <?= esc($ml_branch) ?></span>
+        <span class="ml-sep ml-fwd sep-b-c"></span>
+        <span class="ml-c"><?= esc($ml_buffer) ?></span>
+        <span class="ml-sep ml-fwd sep-c-bar"></span>
+
+        <span class="ml-fill"></span>
+
+        <span class="ml-sep ml-bwd sep-bar-c"></span>
+        <span class="ml-c">utf-8</span>
+        <span class="ml-sep ml-bwd sep-c-b"></span>
+        <span class="ml-b"><?= esc($ml_type) ?></span>
+        <span class="ml-sep ml-bwd sep-b-a"></span>
+        <span class="ml-a"><?= $ml_info !== '' ? esc($ml_info) : 'Top' ?></span>
+    </div>
+</footer>
+<?php if (!empty($vim_capable)): ?>
+<script type="module" src="/js/vim.js"></script>
+<script type="module" src="/js/upload.js"></script>
+<?php endif; ?>
+<script type="module" src="/js/telescope.js"></script>
+</body>
+</html>
diff --git a/views/Header_View.php b/views/Header_View.php
new file mode 100644
index 0000000..b310dca
--- /dev/null
+++ b/views/Header_View.php
@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title><?= esc($title ?? "Tony's Forums") ?></title>
+    <link rel="stylesheet" href="/css/style.css">
+</head>
+<body>
+<header>
+    <a href="/" class="logo">forums.tonybtw.com</a>
+    <nav class="nav-right">
+        <a href="/search" data-telescope title="Press / to search">search</a>
+        <?php if ($current_user): ?>
+            <?php if (role_of($current_user)->is_admin()): ?><a href="/admin">admin</a><?php endif; ?>
+            <a href="/u/<?= esc($current_user['username']) ?>"><?= esc($current_user['username']) ?></a><?= role_badge($current_user['role']) ?>
+            <form method="post" action="/logout" class="inline-form">
+                <input type="hidden" name="csrf" value="<?= esc(Session::csrf_token()) ?>">
+                <button type="submit" class="link-button">logout</button>
+            </form>
+        <?php else: ?>
+            <a href="/login">login</a>
+            <a href="/register">register</a>
+        <?php endif; ?>
+    </nav>
+</header>
+<main>
diff --git a/views/Home_View.php b/views/Home_View.php
new file mode 100644
index 0000000..26f6b46
--- /dev/null
+++ b/views/Home_View.php
@@ -0,0 +1,49 @@
+<?php $title = "Tony's Forums, btw."; ?>
+<?php include PATH_TO_VIEWS_DIR . 'Header_View.php'; ?>
+
+<?php foreach ($categories as $category => $boards): ?>
+<section class="board-category">
+    <h2><?= esc($category) ?></h2>
+    <table class="board-list">
+        <thead>
+            <tr>
+                <th class="col-board">Board</th>
+                <th class="col-num">Topics</th>
+                <th class="col-num">Posts</th>
+                <th class="col-last">Last post</th>
+            </tr>
+        </thead>
+        <tbody>
+            <?php foreach ($boards as $b): ?>
+            <tr>
+                <td class="col-board">
+                    <a href="/board/<?= esc($b['slug']) ?>" class="board-name"><?= esc($b['name']) ?></a>
+                    <div class="board-desc"><?= esc($b['description']) ?></div>
+                </td>
+                <td class="col-num"><?= (int) $b['topic_count'] ?></td>
+                <td class="col-num"><?= (int) $b['post_count'] ?></td>
+                <td class="col-last">
+                    <?php if ($b['last_thread_id']): ?>
+                        <a href="/thread/<?= (int) $b['last_thread_id'] ?>" class="last-title"><?= esc($b['last_title']) ?></a>
+                        <div class="last-meta">
+                            by <a href="/u/<?= esc($b['last_user']) ?>"><?= esc($b['last_user']) ?></a>
+                            <?= esc(time_ago((int) $b['last_at'])) ?>
+                        </div>
+                    <?php else: ?>
+                        <span class="muted">no posts yet</span>
+                    <?php endif; ?>
+                </td>
+            </tr>
+            <?php endforeach; ?>
+        </tbody>
+    </table>
+</section>
+<?php endforeach; ?>
+
+<?php
+$board_total = array_sum(array_map('count', $categories));
+$ml_buffer = 'forums';
+$ml_type = 'index';
+$ml_info = $board_total . ' boards';
+include PATH_TO_VIEWS_DIR . 'Footer_View.php';
+?>
diff --git a/views/Login_View.php b/views/Login_View.php
new file mode 100644
index 0000000..dd44433
--- /dev/null
+++ b/views/Login_View.php
@@ -0,0 +1,20 @@
+<?php $title = "Log in — Tony's Forums"; ?>
+<?php include PATH_TO_VIEWS_DIR . 'Header_View.php'; ?>
+
+<div class="auth-form">
+    <h1>Log in</h1>
+
+    <?php include PATH_TO_VIEWS_DIR . 'Errors_View.php'; ?>
+
+    <form method="post" action="/login" class="stacked-form">
+        <input type="hidden" name="csrf" value="<?= esc(Session::csrf_token()) ?>">
+        <label for="username">Username</label>
+        <input type="text" id="username" name="username" value="<?= esc($values['username']) ?>" required autofocus>
+        <label for="password">Password</label>
+        <input type="password" id="password" name="password" required>
+        <button type="submit">Log in</button>
+    </form>
+    <p class="auth-alt">No account? <a href="/register">Register</a>.</p>
+</div>
+
+<?php include PATH_TO_VIEWS_DIR . 'Footer_View.php'; ?>
diff --git a/views/Markup_Hint_View.php b/views/Markup_Hint_View.php
new file mode 100644
index 0000000..d09c6da
--- /dev/null
+++ b/views/Markup_Hint_View.php
@@ -0,0 +1,3 @@
+<p class="markup-hint">
+    Markdown: <code>**bold**</code> <code>*italic*</code> <code>`code`</code> <code>&gt; quote</code> <code>- list</code> <code>[text](url)</code> and <code>```lang</code> fenced code blocks.
+</p>
diff --git a/views/New_Thread_View.php b/views/New_Thread_View.php
new file mode 100644
index 0000000..ae3a6d9
--- /dev/null
+++ b/views/New_Thread_View.php
@@ -0,0 +1,42 @@
+<?php $title = "New thread in " . $board['name'] . " — Tony's Forums"; ?>
+<?php include PATH_TO_VIEWS_DIR . 'Header_View.php'; ?>
+
+<div class="breadcrumb">
+    <a href="/">forums</a> /
+    <a href="/board/<?= esc($board['slug']) ?>"><?= esc($board['name']) ?></a> /
+    new thread
+</div>
+
+<h1>New thread</h1>
+
+<?php include PATH_TO_VIEWS_DIR . 'Errors_View.php'; ?>
+
+<?php if (!empty($preview)): ?>
+<div class="preview">
+    <div class="preview-label">Preview</div>
+    <div class="post-body"><?= $preview ?></div>
+</div>
+<?php endif; ?>
+
+<form method="post" action="/board/<?= esc($board['slug']) ?>/new" class="stacked-form">
+    <input type="hidden" name="csrf" value="<?= esc(Session::csrf_token()) ?>">
+    <label for="title">Title</label>
+    <input type="text" id="title" name="title" value="<?= esc($values['title']) ?>" maxlength="200" required autofocus>
+    <label for="body">Post</label>
+    <textarea id="body" name="body" rows="12" class="vim-area" required><?= esc($values['body']) ?></textarea>
+    <?php include PATH_TO_VIEWS_DIR . 'Post_Tools_View.php'; ?>
+    <?php include PATH_TO_VIEWS_DIR . 'Markup_Hint_View.php'; ?>
+    <div class="form-actions">
+        <button type="submit" name="action" value="create">Create thread</button>
+        <button type="submit" name="action" value="preview" class="secondary" formnovalidate>Preview</button>
+        <label class="vim-toggle"><input type="checkbox"> vim</label>
+    </div>
+</form>
+
+<?php
+$ml_mode = 'INSERT';
+$ml_buffer = 'board/' . $board['slug'] . '/new';
+$ml_type = 'new [+]';
+$vim_capable = true;
+include PATH_TO_VIEWS_DIR . 'Footer_View.php';
+?>
diff --git a/views/Post_Tools_View.php b/views/Post_Tools_View.php
new file mode 100644
index 0000000..d2c3781
--- /dev/null
+++ b/views/Post_Tools_View.php
@@ -0,0 +1,5 @@
+<div class="post-tools">
+    <a href="/upload" class="upload-btn" data-upload>attach image</a>
+    <input type="file" class="upload-input" accept="image/png,image/jpeg,image/gif,image/webp" hidden>
+    <span class="upload-status"></span>
+</div>
diff --git a/views/Register_View.php b/views/Register_View.php
new file mode 100644
index 0000000..3484b3e
--- /dev/null
+++ b/views/Register_View.php
@@ -0,0 +1,22 @@
+<?php $title = "Register — Tony's Forums"; ?>
+<?php include PATH_TO_VIEWS_DIR . 'Header_View.php'; ?>
+
+<div class="auth-form">
+    <h1>Register</h1>
+
+    <?php include PATH_TO_VIEWS_DIR . 'Errors_View.php'; ?>
+
+    <form method="post" action="/register" class="stacked-form">
+        <input type="hidden" name="csrf" value="<?= esc(Session::csrf_token()) ?>">
+        <label for="username">Username</label>
+        <input type="text" id="username" name="username" value="<?= esc($values['username']) ?>" pattern="[A-Za-z0-9_]{3,20}" required autofocus>
+        <label for="password">Password</label>
+        <input type="password" id="password" name="password" minlength="6" required>
+        <label for="confirm">Confirm password</label>
+        <input type="password" id="confirm" name="confirm" minlength="6" required>
+        <button type="submit">Create account</button>
+    </form>
+    <p class="auth-alt">Already registered? <a href="/login">Log in</a>.</p>
+</div>
+
+<?php include PATH_TO_VIEWS_DIR . 'Footer_View.php'; ?>
diff --git a/views/Search_View.php b/views/Search_View.php
new file mode 100644
index 0000000..8b45be9
--- /dev/null
+++ b/views/Search_View.php
@@ -0,0 +1,37 @@
+<?php $title = ($q !== '' ? $q . ' — ' : '') . "Search — Tony's Forums"; ?>
+<?php include PATH_TO_VIEWS_DIR . 'Header_View.php'; ?>
+
+<div class="breadcrumb"><a href="/">forums</a> / search</div>
+
+<h1>Search</h1>
+
+<form method="get" action="/search" class="search-form">
+    <input type="search" name="q" value="<?= esc($q) ?>" placeholder="Search threads and posts..." autofocus>
+    <button type="submit">Search</button>
+</form>
+
+<?php if ($q !== ''): ?>
+    <?php if (empty($results)): ?>
+        <p class="empty">No results for <strong><?= esc($q) ?></strong>.</p>
+    <?php else: ?>
+        <p class="search-count"><?= count($results) ?> result<?= count($results) === 1 ? '' : 's' ?></p>
+        <ul class="search-results">
+            <?php foreach ($results as $r): ?>
+            <li>
+                <a href="/thread/<?= (int) $r['thread_id'] ?>" class="result-title"><?= esc($r['title']) ?></a>
+                <?php if ($r['board_slug']): ?>
+                    <a href="/board/<?= esc($r['board_slug']) ?>" class="result-board"><?= esc($r['board_slug']) ?></a>
+                <?php endif; ?>
+                <div class="result-snippet"><?= $r['snippet'] ?></div>
+            </li>
+            <?php endforeach; ?>
+        </ul>
+    <?php endif; ?>
+<?php endif; ?>
+
+<?php
+$ml_buffer = 'search';
+$ml_type = 'search';
+$ml_info = $q !== '' ? count($results) . ' results' : '';
+include PATH_TO_VIEWS_DIR . 'Footer_View.php';
+?>
diff --git a/views/Thread_View.php b/views/Thread_View.php
new file mode 100644
index 0000000..afbe4f1
--- /dev/null
+++ b/views/Thread_View.php
@@ -0,0 +1,96 @@
+<?php $title = $thread['title'] . " — Tony's Forums"; ?>
+<?php include PATH_TO_VIEWS_DIR . 'Header_View.php'; ?>
+
+<div class="breadcrumb">
+    <a href="/">forums</a> /
+    <?php if ($thread['board_slug']): ?>
+        <a href="/board/<?= esc($thread['board_slug']) ?>"><?= esc($thread['board_name']) ?></a> /
+    <?php endif; ?>
+    <?= esc($thread['title']) ?>
+</div>
+
+<div class="thread-header">
+    <h1>
+        <?php if ($thread['pinned']): ?><span class="badge badge-pin">pinned</span><?php endif; ?>
+        <?php if ($thread['locked']): ?><span class="badge badge-lock">locked</span><?php endif; ?>
+        <?= esc($thread['title']) ?>
+    </h1>
+    <p class="thread-meta">
+        started by <a href="/u/<?= esc($thread['username']) ?>"><?= esc($thread['username']) ?></a>
+        <?= esc(time_ago((int) $thread['created_at'])) ?>
+    </p>
+
+    <?php if (role_of($current_user)->is_mod()): ?>
+    <div class="mod-bar">
+        <span class="mod-label">mod</span>
+        <form method="post" action="/thread/<?= (int) $thread['id'] ?>/pin" class="inline-form">
+            <input type="hidden" name="csrf" value="<?= esc(Session::csrf_token()) ?>">
+            <button type="submit" class="link-button"><?= $thread['pinned'] ? 'unpin' : 'pin' ?></button>
+        </form>
+        <form method="post" action="/thread/<?= (int) $thread['id'] ?>/lock" class="inline-form">
+            <input type="hidden" name="csrf" value="<?= esc(Session::csrf_token()) ?>">
+            <button type="submit" class="link-button"><?= $thread['locked'] ? 'unlock' : 'lock' ?></button>
+        </form>
+        <form method="post" action="/thread/<?= (int) $thread['id'] ?>/delete" class="inline-form">
+            <input type="hidden" name="csrf" value="<?= esc(Session::csrf_token()) ?>">
+            <button type="submit" class="link-button danger">delete thread</button>
+        </form>
+    </div>
+    <?php endif; ?>
+</div>
+
+<div class="posts">
+    <?php foreach ($posts as $i => $p): ?>
+    <article class="post" id="post-<?= (int) $p['id'] ?>">
+        <div class="post-meta">
+            <a href="/u/<?= esc($p['username']) ?>" class="post-author"><?= esc($p['username']) ?></a>
+            <?= role_badge($p['role']) ?>
+            <?php if ((int) $p['id'] === $first_post_id): ?><span class="badge">OP</span><?php endif; ?>
+            <span class="post-date"><?= esc(time_ago((int) $p['created_at'])) ?></span>
+            <?php if (role_of($current_user)->is_mod() && (int) $p['id'] !== $first_post_id): ?>
+            <form method="post" action="/post/<?= (int) $p['id'] ?>/delete" class="inline-form post-delete">
+                <input type="hidden" name="csrf" value="<?= esc(Session::csrf_token()) ?>">
+                <button type="submit" class="link-button danger">delete</button>
+            </form>
+            <?php endif; ?>
+            <a href="#post-<?= (int) $p['id'] ?>" class="post-anchor">#<?= $i + 1 ?></a>
+        </div>
+        <div class="post-body"><?= Markup::format($p['body']) ?></div>
+    </article>
+    <?php endforeach; ?>
+</div>
+
+<div id="bottom"></div>
+
+<?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): ?>
+<?php if (!empty($reply_preview ?? null)): ?>
+<div class="preview">
+    <div class="preview-label">Preview</div>
+    <div class="post-body"><?= $reply_preview ?></div>
+</div>
+<?php endif; ?>
+<form method="post" action="/thread/<?= (int) $thread['id'] ?>/reply" class="reply-form">
+    <h3>Reply<?php if ($thread['locked']): ?> <span class="muted">(locked — mod reply)</span><?php endif; ?></h3>
+    <input type="hidden" name="csrf" value="<?= esc(Session::csrf_token()) ?>">
+    <textarea name="body" rows="6" class="vim-area" placeholder="Write a reply..." required><?= esc($reply_draft ?? '') ?></textarea>
+    <?php include PATH_TO_VIEWS_DIR . 'Post_Tools_View.php'; ?>
+    <?php include PATH_TO_VIEWS_DIR . 'Markup_Hint_View.php'; ?>
+    <div class="form-actions">
+        <button type="submit" name="action" value="post">Post reply</button>
+        <button type="submit" name="action" value="preview" class="secondary" formnovalidate>Preview</button>
+        <label class="vim-toggle"><input type="checkbox"> vim</label>
+    </div>
+</form>
+<?php else: ?>
+<p class="login-prompt"><a href="/login">Log in</a> or <a href="/register">register</a> to reply.</p>
+<?php endif; ?>
+
+<?php
+$ml_buffer = ($thread['board_slug'] ? $thread['board_slug'] . '/' : '') . '#' . $thread['id'];
+$ml_type = $thread['locked'] ? 'thread [-]' : 'thread';
+$ml_info = count($posts) . (count($posts) === 1 ? ' post' : ' posts');
+$vim_capable = true;
+include PATH_TO_VIEWS_DIR . 'Footer_View.php';
+?>
diff --git a/views/Upload_View.php b/views/Upload_View.php
new file mode 100644
index 0000000..d2cb7d0
--- /dev/null
+++ b/views/Upload_View.php
@@ -0,0 +1,31 @@
+<?php $title = "Upload image — Tony's Forums"; ?>
+<?php include PATH_TO_VIEWS_DIR . 'Header_View.php'; ?>
+
+<div class="breadcrumb"><a href="/">forums</a> / upload</div>
+
+<h1>Upload an image</h1>
+
+<?php include PATH_TO_VIEWS_DIR . 'Errors_View.php'; ?>
+
+<?php if ($result): ?>
+<div class="upload-result">
+    <p>Uploaded. Paste this into your post:</p>
+    <input type="text" readonly value="![](<?= esc($result) ?>)" onclick="this.select()">
+    <div class="upload-preview"><img src="<?= esc($result) ?>" alt="uploaded image"></div>
+</div>
+<?php endif; ?>
+
+<form method="post" action="/upload" enctype="multipart/form-data" class="stacked-form">
+    <input type="hidden" name="csrf" value="<?= esc(Session::csrf_token()) ?>">
+    <label for="image">Image (PNG, JPEG, GIF, WebP — up to <?= (int) (Upload::MAX_BYTES / 1048576) ?> MB)</label>
+    <input type="file" id="image" name="image" accept="image/png,image/jpeg,image/gif,image/webp" required>
+    <button type="submit">Upload</button>
+</form>
+
+<p class="markup-hint">Tip: with JavaScript enabled you can paste or drag images straight into a post.</p>
+
+<?php
+$ml_buffer = 'upload';
+$ml_type = 'upload';
+include PATH_TO_VIEWS_DIR . 'Footer_View.php';
+?>
diff --git a/views/User_View.php b/views/User_View.php
new file mode 100644
index 0000000..9076dbf
--- /dev/null
+++ b/views/User_View.php
@@ -0,0 +1,48 @@
+<?php $title = $user['username'] . " — Tony's Forums"; ?>
+<?php include PATH_TO_VIEWS_DIR . 'Header_View.php'; ?>
+
+<div class="profile-header">
+    <h1><?= esc($user['username']) ?> <?= role_badge($user['role']) ?></h1>
+    <p class="profile-meta">
+        joined <?= esc(date('Y-m-d', (int) $user['created_at'])) ?>
+        &middot; <?= (int) $stats['threads'] ?> threads
+        &middot; <?= (int) $stats['posts'] ?> posts
+    </p>
+</div>
+
+<h2>Threads</h2>
+<?php if (empty($user_threads)): ?>
+    <p class="empty">No threads yet.</p>
+<?php else: ?>
+<table class="thread-list">
+    <thead>
+        <tr>
+            <th>Title</th>
+            <th>Board</th>
+            <th>Replies</th>
+            <th>Created</th>
+        </tr>
+    </thead>
+    <tbody>
+        <?php foreach ($user_threads as $t): ?>
+        <tr>
+            <td class="title"><a href="/thread/<?= (int) $t['id'] ?>"><?= esc($t['title']) ?></a></td>
+            <td class="author">
+                <?php if ($t['board_slug']): ?>
+                    <a href="/board/<?= esc($t['board_slug']) ?>"><?= esc($t['board_name']) ?></a>
+                <?php else: ?>&mdash;<?php endif; ?>
+            </td>
+            <td class="replies"><?= max(0, (int) $t['post_count'] - 1) ?></td>
+            <td class="activity"><?= esc(date('Y-m-d', (int) $t['created_at'])) ?></td>
+        </tr>
+        <?php endforeach; ?>
+    </tbody>
+</table>
+<?php endif; ?>
+
+<?php
+$ml_buffer = 'u/' . $user['username'];
+$ml_type = 'profile';
+$ml_info = $stats['threads'] . 't ' . $stats['posts'] . 'p';
+include PATH_TO_VIEWS_DIR . 'Footer_View.php';
+?>