import { type Context, Hono } from "hono"; import type { ContentfulStatusCode } from "hono/utils/http-status"; import { adjectives, animals, colors, uniqueNamesGenerator, } from "unique-names-generator"; import { hasAdminToken } from "../admin"; import { regenerateAssets, serveAsset } from "../assets"; import { PendingQuestion, Question } from "../db"; import { renderDynamicPage } from "../framework/dynamic-pages"; import { getQuestionImage } from "./question_image"; import { formatQuestionId, questionIdToTimestamp } from "./QuestionRender"; import process from "node:process"; const PROXYCHECK_API_KEY = process.env.PROXYCHECK_API_KEY; let isStale = false; const app = new Hono(); // Main page app.get("/q+a", async (c) => { if (isStale) { await regenerateAssets(); isStale = false; } if (hasAdminToken(c)) { return serveAsset(c, "/admin/q+a", 200); } return serveAsset(c, "/q+a", 200); }); // Submit form app.post("/q+a", async (c) => { const form = await c.req.formData(); let text = form.get("text"); if (typeof text !== "string") { return questionFailure(c, 400, "Bad Request"); } text = text.trim(); const input = { date: new Date(), prompt: text, sourceName: "unknown", sourceLocation: "unknown", sourceVPN: null, }; input.date.setMilliseconds(0); if (text.length <= 0) { return questionFailure(c, 400, "Content is too short", text); } if (text.length > 16000) { return questionFailure(c, 400, "Content is too long", text); } // Ban patterns if ( text.includes("hams" + "terko" + "mbat" + ".expert") // 2025-02-18. 3 occurrences. All VPN ) { // To prevent known automatic spam-bots from noticing something automatic is // happening, pretend that the question was successfully submitted. return sendSuccess(c, new Date()); } const ipAddr = c.req.header("cf-connecting-ip"); if (ipAddr) { input.sourceName = uniqueNamesGenerator({ dictionaries: [adjectives, colors, animals], separator: "-", seed: ipAddr + PROXYCHECK_API_KEY, }); } const cfIPCountry = c.req.header("cf-ipcountry"); if (cfIPCountry) { input.sourceLocation = cfIPCountry; } if (ipAddr && PROXYCHECK_API_KEY) { const proxyCheck = await fetch( `https://proxycheck.io/v2/?key=${PROXYCHECK_API_KEY}&risk=1&vpn=1`, { method: "POST", body: "ips=" + ipAddr, headers: { "Content-Type": "application/x-www-form-urlencoded", }, }, ).then((res) => res.json()); if (ipAddr && proxyCheck[ipAddr]) { if (proxyCheck[ipAddr].proxy === "yes") { input.sourceVPN = proxyCheck[ipAddr].operator?.name ?? proxyCheck[ipAddr].organisation ?? proxyCheck[ipAddr].provider ?? "unknown"; } if (Number(proxyCheck[ipAddr].risk) > 72) { return questionFailure( c, 403, "This IP address has been flagged as a high risk IP address. If you are using a VPN/Proxy, please disable it and try again.", text, ); } } } const date = Question.create( Question.Type.pending, JSON.stringify(input), input.date, ); await sendSuccess(c, date); }); async function sendSuccess(c: Context, date: Date) { if (c.req.header("Accept")?.includes("application/json")) { return c.json({ success: true, message: "ok", date: date.getTime(), id: formatQuestionId(date), }, { status: 200 }); } c.res = await renderDynamicPage(c.req.raw, "qa_success", { permalink: `https://paperclover.net/q+a/${formatQuestionId(date)}`, }); } // Question Permalink app.get("/q+a/:id", async (c, next) => { // from deadname era, the seconds used to be in the url. // this was removed so that the url can be crafted by hand. let id = c.req.param("id"); if (id.length === 12 && /^\d+$/.test(id)) { return c.redirect(`/q+a/${id.slice(0, 10)}`); } let image = false; if (id.endsWith(".png")) { image = true; id = id.slice(0, -4); } const timestamp = questionIdToTimestamp(id); if (!timestamp) return next(); const question = await Question.getByDate(timestamp); if (!question) return next(); if (image) { return getQuestionImage(question, c.req.method === "HEAD"); } return renderDynamicPage(c.req.raw, "qa_permalink", { question }); }); // Admin app.get("/admin/q+a", async (c) => { if (isStale) { await regenerateAssets(); isStale = false; } return serveAsset(c, "/admin/q+a", 200); }); app.get("/admin/q+a/inbox", async (c) => { return renderDynamicPage(c.req.raw, "qa_backend_inbox", {}); }); app.delete("/admin/q+a/:id", async (c, next) => { const id = c.req.param("id"); const timestamp = questionIdToTimestamp(id); if (!timestamp) return next(); const question = Question.getByDate(timestamp); if (!question) return next(); const deleteFull = c.req.header("X-Delete-Full") === "true"; if (deleteFull) { Question.deleteByQmid(question.qmid); } else { Question.rejectByQmid(question.qmid); } return c.json({ success: true, message: "ok" }); }); app.patch("/admin/q+a/:id", async (c, next) => { const id = c.req.param("id"); const timestamp = questionIdToTimestamp(id); if (!timestamp) return next(); const question = Question.getByDate(timestamp); if (!question) return next(); const form = await c.req.raw.json(); if (typeof form.text !== "string" || typeof form.type !== "number") { console.log("bad request"); return questionFailure(c, 400, "Bad Request"); } Question.updateByQmid(question.qmid, form.text, form.type); isStale = true; return c.json({ success: true, message: "ok" }); }); app.get("/admin/q+a/:id", async (c, next) => { const id = c.req.param("id"); const timestamp = questionIdToTimestamp(id); if (!timestamp) return next(); const question = Question.getByDate(timestamp); if (!question) return next(); let pendingInfo: null | PendingQuestion.JSON = null; if (question.type === Question.Type.pending) { pendingInfo = JSON.parse(question.text) as PendingQuestion.JSON; question.text = pendingInfo.prompt.trim().split("\n").map((line) => line.trim().length === 0 ? "" : `q: ${line.trim()}` ).join("\n") + "\n\n"; question.type = Question.Type.normal; } return renderDynamicPage(c.req.raw, "qa_backend_edit", { pendingInfo, question, }); }); app.get("/q+a/things/random", async (c) => { c.res = await renderDynamicPage(c.req.raw, "qa_things_random", {}); }); // 404 app.get("/q+a/*", async (c) => { return serveAsset(c, "/q+a/404", 404); }); async function questionFailure( c: Context, status: ContentfulStatusCode, message: string, content?: string, ) { if (c.req.header("Accept")?.includes("application/json")) { return c.json({ success: false, message, id: null }, { status }); } c.res = await renderDynamicPage(c.req.raw, "qa_fail", { error: message, content, }); } export { app as questionAnswerApp };