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 |
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
/adminafter first login - Threads with an OP post and replies, scoped to a board
- Roles:
admin,mod,normal(first registered user becomesadmin)- 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 isnormal. 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 bundledHlhighlighter (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
/orCtrl+K): live results, list + preview,↑↓/Ctrl-j/kto move,⏎to open. Progressive enhancement over the/searchpage. - Image uploads (login required) - paste, drag-drop, or pick a file into a post box; inserts
Markdown. Local disk underpublic/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/.htaccessdisables script execution + setsnosniff(Apache); on nginx, serve/uploadsstatically and don't route it through PHP. - No-JS fallback: the
/uploadpage returns a Markdown snippet to paste. - Dev server raises PHP upload limits via flags in the
justfile; production must setupload_max_filesize/post_max_sizeaccordingly.
- Security: validated by content (
- 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, visuald y c x, and:w/:wq/:xto 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.