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.
242 lines
6.9 KiB
TypeScript
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 };
|