sitegen/src/q+a/backend.ts
chloe caruso af60d1172f i accidentally deleted the repo, but recovered it. i'll start committing
it was weird. i pressed delete on a subfolder, i think one of the
pages.off folders that i was using. and then, suddenly, nvim on windows
7 decided to delete every file in the directory. they weren't shred off
the space time continuum, but just marked deleted. i had to pay $80 to
get access to a software that could see them. bleh!

just seeing all my work, a little over a week, was pretty heart
shattering. but i remembered that long ago, a close friend said i could
call them whenever i was feeling sad. i finally took them up on that
offer. the first time i've ever called someone for emotional support.
but it's ok. i got it back. and the site framework is better than ever.

i'm gonna commit and push more often. the repo is private anyways.
2025-06-06 23:38:02 -07:00

242 lines
6.9 KiB
TypeScript

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 };