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 = '#<(/?)(' . implode('|', self::TAGS) . ')>#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>↑↓ move</span><span>⏎ 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  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, '');
+ } 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 "<?= esc($b['slug']) ?>"?');">
+ <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 ?>">← 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 →</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"> <?= 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>> 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=" ?>)" 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'])) ?>
+ · <?= (int) $stats['threads'] ?> threads
+ · <?= (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: ?>—<?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';
+?>