How I Built My Ask Box
I like answering silly questions :3
After seeing Kett’s ask box I wanted to have my own ask box, it seemed pretty fun to recieve and answer questions from friends and mutuals, and as a nerd who likes making stuff myself, this was no exception! Since my backend is Astro, the implentation will be particular to how Astro does things, but the concepts are transferable.
Without further ado, let’s start with the client side of things. It is just an HTML form that posts to the same URL when the button is pressed. autocomplete="off"
prevents your browser from trying to automatically fill it with your information, and the question is the only required
input.
<form method="POST" autocomplete="off">
<label>
Name
<input type="text" name="name" placeholder="anonymous" />
</label>
<label class="offscreen">
Email
<input type="email" name="email" tabindex="-1" />
</label>
<label>
Question
<textarea name="question" rows="5" required />
</label>
<button>Ask</button>
</form>
The email input is actually a honeypot! The tabindex
is set to -1
to prevent you from navigating to it by keyboard, and the offscreen
class hides it off-screen. This is preferred to using display: none;
or visiblity: hidden;
as simple bots can test for this without needing a full browser to test if a position is on-screen.
.offscreen {
position: fixed;
top: 100%;
left: 100%;
}
In the frontmatter of the ask box page I have a separate route to handle POST requests, and it has a simple IP-based rate limiter using the lambda-rate-limiter library. For this to work properly, I need to mark the page as dynamic.
// It is stored as a singleton in the config file
import { rateLimit } from "astro.config.mjs";
export const prerender = false;
if (Astro.request.method === "POST") {
try {
await rateLimit(2, Astro.clientAddress);
} catch (error) {
return new Response("Too many requests", { status: 429 });
}
// ...
}
After passing the rate limiter, I fetch the form data, and if the name was empty I change it to anonymous.
const data = await Astro.request.formData();
let name = data.get("name");
const question = data.get("question");
if (name === "") { name = "anonymous"; }
If the question was empty (because you bypassed the required
attribute) or the email was filled, the honeypot activates! It logs your name and IP address, and ignores your question.
if (question === "") {
throw new Error(`Honeypot triggered:\n ${name} from ${Astro.clientAddress}`);
}
if (data.get("email")) {
throw new Error(`Honeypot triggered:\n ${name} asked "${question}" from ${Astro.clientAddress}`);
}
Next, I read the askbox.json
file, push the new question to the end of the list, and write it back to the disk.
let askboxJson = fs.readFileSync("./src/data/askbox.json", "utf-8");
let askbox: any[] = JSON.parse(askboxJson);
askbox.push({
name: name,
question: question,
answer: "",
date: global.Date.now(),
id: (askbox.at(-1).id ?? -1) + 1
});
askboxJson = JSON.stringify(askbox, null, "\t");
fs.writeFileSync("./src/data/askbox.json", askboxJson, "utf-8");
Because my website is pulled from a git repo, I can actually push the changes straight to the repo, letting me easily answer questions anywhere I can login to GitHub! I include “[skip ci]” in the commit message to prevent it from activating my deploy action.
// Only push if in the production environment
if (import.meta.env.PROD) {
exec('git add ./src/data/askbox.json && git commit -m "[skip ci] Askbox" && git push', { encoding: "utf-8" });
}
To alert me to new questions, I send a webhook to a private Discord channel, and then I redirect the client to a funny URL name :3
fetch("https://discord.com/api/webhooks/xxxxxxxxxx", {
method: "POST",
headers: { "Content-type": "application/json" },
body: JSON.stringify({
content: `**${name}** asked you a question!\n\n>>> ${question}`
})
});
return Astro.redirect("/ask?ed");
Rendering the questions is a matter of loading the json file, filtering out unanswered questions, and mapping them to HTML. With Astro I use content collections to give myself a super easy API to query the json file.
const askbox = (await getCollection("askbox"))
.map(entry => entry.data)
.filter(entry => entry.answer)
.reverse();
Yep, that’s really it!
If you’re curious, the question rendering looks like this. Each entry in the askbox is mapped to an HTML fragment and filled with the entry’s data. <Date />
is a custom JSX component I made that handles rendering dates the way I like. entry.answer
is actually markdown, but you can assume it is HTML for simplicity.
{askbox.map(entry => (
<hr>
<div>
<blockquote class="question">
<div class="name-and-date">
<span>{entry.name}</span>
<Date date={entry.date} />
</div>
{entry.question.split("\n").map(line => (
<p>{line}</p>
))}
</blockquote>
<div class="answer" set:html={entry.answer} />
</div>
))}

Likes
Mups ????️???? ????????sharing your own askbox code or askbox instance? :P
ChaiI did share the meat and bones of the askbox code in the snippets, are you asking for a raw GitHub gist instead?
Mups ????️???? ????????oh uhh i don't understand anymore what you're talking about lol i mean i have my friends askbox code thing, catask.mystie.dev