Message board
Messages are stored in SQLite. SQL can only be written as sql"..." literals, and every
{$value} inside one becomes a placeholder, so quotes and '); DROP TABLE are just text.
The form carries a CSRF token that the server checks before the script runs.
No messages in the last 24 hours. Be the first!
Source of this page
Read from disk by lib/source.phx as the page is served.
public/board.phx
<?phx
import std.http.{request, response, session};
import lib.client.client_ip;
import lib.source.sources;
import lib.store.{demo_db, recent_messages, add_message, validate_message};
$req = request();
$db = demo_db();
$name = session()->get("name") ?? "";
$text = "";
list<string> $errors = [];
// The server has already checked the CSRF token before this line runs:
// a POST without a valid csrf_field() never gets here.
if ($req->isPost()) {
$name = ($req->form("name") ?? "")->trim();
$text = ($req->form("text") ?? "")->trim();
$errors = validate_message($name, $text);
$ip = client_ip();
if ($errors->isEmpty() && (!rate_limit("board:" . $ip, 3, 60) || !rate_limit("board:" . $ip, 20, 86400))) {
$errors[] = "Too many messages from your address; please wait a little.";
}
if ($errors->isEmpty()) {
add_message($db, $name, $text);
session()->set("name", $name);
session()->set("notice", "Posted. It stays up for 24 hours.");
response()->redirect("/board"); // post/redirect/get
return;
}
}
$body = render(
"views/board.phx.html",
messages: recent_messages($db),
name: $name,
text: $text,
errors: $errors,
notice: session()->flash("notice"),
);
echo render(
"views/layout.phx.html",
title: "Message board",
path: "/board",
body: $body,
files: sources(["public/board.phx", "lib/store.phx", "views/board.phx.html"]),
);
lib/store.phx
import std.db.Database;
// One SQLite file holds the message board and the click counter.
// Each request opens it again: nothing is shared between requests.
export class Message {
public function __construct(
public readonly int $id,
public readonly string $name,
public readonly string $text,
public readonly int $createdAt,
) {}
public function time(): string {
return gmdate("Y-m-d H:i", $this->createdAt) . " UTC";
}
}
const KEEP_SECONDS = 86400;
export function demo_db(): Database {
$db = Database::open("data/demo.db");
$db->execute(sql"CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
text TEXT NOT NULL,
created_at INTEGER NOT NULL
)");
$db->execute(sql"CREATE TABLE IF NOT EXISTS counters (
name TEXT PRIMARY KEY,
value INTEGER NOT NULL
)");
return $db;
}
// Messages are kept for a day.
export function recent_messages(Database $db, int $limit = 30): list<Message> {
$cutoff = time() - KEEP_SECONDS;
$db->execute(sql"DELETE FROM messages WHERE created_at < {$cutoff}");
$rows = $db->query(sql"SELECT id, name, text, created_at FROM messages ORDER BY id DESC LIMIT {$limit}");
return $rows->map(fn($r) => new Message(
$r->int("id") ?? 0,
$r->string("name") ?? "",
$r->string("text") ?? "",
$r->int("created_at") ?? 0,
));
}
export function add_message(Database $db, string $name, string $text): void {
$now = time();
$db->execute(sql"INSERT INTO messages (name, text, created_at) VALUES ({$name}, {$text}, {$now})");
}
// Returns the problems with a message, or an empty list.
export function validate_message(string $name, string $text): list<string> {
list<string> $errors = [];
if ($name->isEmpty() || $name->len() > 40) {
$errors[] = "Please enter a name (up to 40 characters).";
}
if ($text->isEmpty() || $text->len() > 280) {
$errors[] = "Please write a message (up to 280 characters).";
}
$lower = $text->lower();
if ($lower->contains("http://") || $lower->contains("https://") || $lower->contains("www.")) {
$errors[] = "Links are not allowed on this board.";
}
return $errors;
}
export function bump_counter(Database $db, string $name): int {
$db->execute(sql"INSERT INTO counters (name, value) VALUES ({$name}, 1)
ON CONFLICT(name) DO UPDATE SET value = value + 1");
return counter($db, $name);
}
export function counter(Database $db, string $name): int {
return $db->first(sql"SELECT value FROM counters WHERE name = {$name}")?->int("value") ?? 0;
}
views/board.phx.html
<?phx
import lib.store.Message;
params(list<Message> $messages, string $name, string $text, list<string> $errors, ?string $notice);
?>
<h1>Message board</h1>
<p class="lead">Messages are stored in SQLite. SQL can only be written as <code>sql"..."</code> literals, and every
<code>{$value}</code> inside one becomes a placeholder, so quotes and <code>'); DROP TABLE</code> are just text.
The form carries a CSRF token that the server checks before the script runs.</p>
<?phx if ($notice != null): ?>
<div class="notice ok"><?= $notice ?></div>
<?phx endif; ?>
<?phx if (!$errors->isEmpty()): ?>
<ul class="notice error">
<?phx foreach ($errors as $error): ?>
<li><?= $error ?></li>
<?phx endforeach; ?>
</ul>
<?phx endif; ?>
<form method="post" action="/board" class="stack">
<?= csrf_field() ?>
<label for="name">Name</label>
<input id="name" name="name" value="<?= $name ?>" maxlength="40" required>
<label for="text">Message</label>
<textarea id="text" name="text" rows="3" maxlength="280" required><?= $text ?></textarea>
<button>Post</button>
<p class="muted small">Public. Kept for 24 hours, no links, 3 posts a minute.</p>
</form>
<?phx foreach ($messages as $message): ?>
<article class="message">
<p class="meta"><strong><?= $message->name ?></strong> <span><?= $message->time() ?></span></p>
<p><?= nl2br($message->text) ?></p>
</article>
<?phx endforeach; ?>
<?phx if ($messages->isEmpty()): ?>
<p class="muted">No messages in the last 24 hours. Be the first!</p>
<?phx endif; ?>