PHV demo

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.

Public. Kept for 24 hours, no links, 3 posts a minute.

PHV 2026-11-02 12:18 UTC

Hello from PHV v0.3.0 on the new demo

Source of this page

Read from disk by lib/source.phv as the page is served.

public/board.phv
<?phv
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.phv.html",
    messages: recent_messages($db),
    name: $name,
    text: $text,
    errors: $errors,
    notice: session()->flash("notice"),
);
echo render(
    "views/layout.phv.html",
    title: "Message board",
    path: "/board",
    body: $body,
    files: sources(["public/board.phv", "lib/store.phv", "views/board.phv.html"]),
);
lib/store.phv
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.phv.html
<?phv
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>

<?phv if ($notice != null): ?>
<div class="notice ok"><?= $notice ?></div>
<?phv endif; ?>
<?phv if (!$errors->isEmpty()): ?>
<ul class="notice error">
<?phv foreach ($errors as $error): ?>
  <li><?= $error ?></li>
<?phv endforeach; ?>
</ul>
<?phv 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>

<?phv 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>
<?phv endforeach; ?>
<?phv if ($messages->isEmpty()): ?>
<p class="muted">No messages in the last 24 hours. Be the first!</p>
<?phv endif; ?>