sitegen/src/q+a/backend.ts
2025-08-11 22:38:02 -07:00

226 lines
6.5 KiB
TypeScript

const PROXYCHECK_API_KEY = process.env.PROXYCHECK_API_KEY;
export const app = new Hono();
// Main page
app.get("/q+a", async (c) => {
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("x-forwarded-for");
if (ipAddr) {
input.sourceName = uniqueNamesGenerator({
dictionaries: [adjectives, colors, animals],
separator: "-",
seed: ipAddr + PROXYCHECK_API_KEY,
});
}
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) > 78) {
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,
);
}
}
}
view.regenerate("q+a inbox");
const date = Question.create(
QuestionType.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 view.serve(c, "q+a/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 = Question.getByDate(timestamp);
if (!question) return next();
if (image) {
return getQuestionImage(question, c.req.method === "HEAD");
}
return view.serve(c, "q+a/permalink", { question });
});
// Admin
app.get("/admin/q+a", async (c) => {
return serveAsset(c, "/admin/q+a", 200);
});
app.get("/admin/q+a/inbox", async (c) => {
return view.serve(c, "q+a/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);
}
view.regenerate("q+a");
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") {
return questionFailure(c, 400, "Bad Request");
}
Question.updateByQmid(question.qmid, form.text, form.type);
view.regenerate("q+a");
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 | PendingQuestionData = null;
if (question.type === QuestionType.pending) {
pendingInfo = JSON.parse(question.text) as PendingQuestionData;
question.text = pendingInfo.prompt
.trim()
.split("\n")
.map((line) => (line.trim().length === 0 ? "" : `q: ${line.trim()}`))
.join("\n") + "\n\n";
question.type = QuestionType.normal;
}
return view.serve(c, "q+a/editor", {
pendingInfo,
question,
});
});
app.get("/q+a/things/random", async (c) => {
c.res = await view.serve(c, "q+a/things-random", {});
});
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 });
}
return await view.serve(c, "q+a/fail", {
error: message,
content,
});
}
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.ts";
import { serveAsset } from "#sitegen/assets";
import type { PendingQuestionData } from "./models/PendingQuestion.ts";
import { Question, QuestionType } from "./models/Question.ts";
import * as view from "#sitegen/view";
import { getQuestionImage } from "./image.tsx";
import { formatQuestionId, questionIdToTimestamp } from "./format.ts";