forums.tonybtw.com

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

Files

040000 config/
040000 controllers/
040000 lib/
040000 models/
040000 public/
040000 types/
040000 views/
100644 .envrc
100644 .gitignore
100644 flake.lock
100644 flake.nix
100644 justfile
100644 README.md

Recent commits

cfb78ae Add per-board posting permissions (post_min_role) tonybanters 2026-06-05
7d3da8c Seed default board layout + ship woff2 fonts tonybanters 2026-06-05
26a626d asdf tonybanters 2026-06-05

View all commits

README.md

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

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 includes the *_View.php) or redirect().

Conventions: snake_case functions/variables, Upper_Snake classes/types/enums, lowercase /** this function … */ docblocks, heredoc SQL, $dbh/$sth handles.