1const PROXYCHECK_API_KEY = process.env.PROXYCHECK_API_KEY;
2
3export const app = new Hono();
4
5// Main page
6app.get("/q+a", async (c) => {
7 if (hasAdminToken(c)) {
8 return serveAsset(c, "/admin/q+a", 200);
9 }
10 return serveAsset(c, "/q+a", 200);
11});
12
13// Submit form
14app.post("/q+a", async (c) => {
15 const form = await c.req.formData();
16 let text = form.get("text");
17 if (typeof text !== "string") {
18 return questionFailure(c, 400, "Bad Request");
19 }
20 text = text.trim();
21 const input = {
22 date: new Date(),
23 prompt: text,
24 sourceName: "unknown",
25 sourceLocation: "unknown",
26 sourceVPN: null,
27 };
28
29 input.date.setMilliseconds(0);
30
31 if (text.length <= 0) {
32 return questionFailure(c, 400, "Content is too short", text);
33 }
34
35 if (text.length > 16000) {
36 return questionFailure(c, 400, "Content is too long", text);
37 }
38
39 // Ban patterns
40 if (
41 text.includes("hams" + "terko" + "mbat" + ".expert") // 2025-02-18. 3 occurrences. All VPN
42 ) {
43 // To prevent known automatic spam-bots from noticing something automatic is
44 // happening, pretend that the question was successfully submitted.
45 return sendSuccess(c, new Date());
46 }
47
48 const ipAddr = c.req.header("x-forwarded-for");
49 if (ipAddr) {
50 input.sourceName = uniqueNamesGenerator({
51 dictionaries: [adjectives, colors, animals],
52 separator: "-",
53 seed: ipAddr + PROXYCHECK_API_KEY,
54 });
55 }
56
57 if (ipAddr && PROXYCHECK_API_KEY) {
58 const proxyCheck = await fetch(
59 `https://proxycheck.io/v2/?key=${PROXYCHECK_API_KEY}&risk=1&vpn=1`,
60 {
61 method: "POST",
62 body: "ips=" + ipAddr,
63 headers: {
64 "Content-Type": "application/x-www-form-urlencoded",
65 },
66 },
67 ).then((res) => res.json());
68
69 if (ipAddr && proxyCheck[ipAddr]) {
70 if (proxyCheck[ipAddr].proxy === "yes") {
71 input.sourceVPN = proxyCheck[ipAddr].operator?.name
72 ?? proxyCheck[ipAddr].organisation
73 ?? proxyCheck[ipAddr].provider ?? "unknown";
74 }
75 if (Number(proxyCheck[ipAddr].risk) > 78) {
76 return questionFailure(
77 c,
78 403,
79 "This IP address has been flagged as a high risk IP address. If "
80 + "you are using a VPN/Proxy, please disable it and try again.",
81 text,
82 );
83 }
84 }
85 }
86
87 const date = questions.create(
88 "pending",
89 JSON.stringify(input),
90 input.date,
91 );
92 await sendSuccess(c, date);
93});
94async function sendSuccess(c: Context, date: Date) {
95 if (c.req.header("Accept")?.includes("application/json")) {
96 return c.json({
97 success: true,
98 message: "ok",
99 date: date.getTime(),
100 id: formatQuestionId(date),
101 }, { status: 200 });
102 }
103 c.res = await view.serveUncached(c, "q+a/success", {
104 permalink: `https://paperclover.net/q+a/${formatQuestionId(date)}`,
105 });
106}
107// Question Permalink
108app.get("/q+a/:id", async (c, next) => {
109 // from deadname era, the seconds used to be in the url.
110 // this was removed so that the url can be crafted by hand.
111 let id = c.req.param("id");
112 if (id.length === 12 && /^\d+$/.test(id)) {
113 return c.redirect(`/q+a/${id.slice(0, 10)}`);
114 }
115 let image = false;
116 if (id.endsWith(".png")) {
117 image = true;
118 id = id.slice(0, -4);
119 }
120
121 const timestamp = questionIdToTimestamp(id);
122 if (!timestamp) return next();
123 const question = questions.getByDate(timestamp);
124 if (!question) return next();
125
126 if (image) {
127 return serveQuestionImage(c, question);
128 }
129 const ua = c.req.header("User-Agent") ?? "";
130 if (ua.includes("clover-qa-image")) {
131 return view.serveCached(c, "q+a/image-embed", { question });
132 }
133 return view.serveCached(c, "q+a/permalink", { question, isDiscord: ua.toLowerCase().includes("discord") });
134});
135
136// Admin
137app.get("/admin/q+a", async (c) => {
138 return serveAsset(c, "/admin/q+a", 200);
139});
140app.get("/admin/q+a/inbox", async (c) => {
141 return view.serveCached(c, "q+a/backend-inbox", {});
142});
143app.delete("/admin/q+a/:id", async (c, next) => {
144 const id = c.req.param("id");
145 const timestamp = questionIdToTimestamp(id);
146 if (!timestamp) return next();
147 const question = questions.getByDate(timestamp);
148 if (!question) return next();
149 const deleteFull = c.req.header("X-Delete-Full") === "true";
150 if (deleteFull) {
151 questions.deleteByQmid(question.qmid);
152 } else {
153 questions.rejectByQmid(question.qmid);
154 }
155 return c.json({ success: true, message: "ok" });
156});
157app.patch("/admin/q+a/:id", async (c, next) => {
158 const id = c.req.param("id");
159 const timestamp = questionIdToTimestamp(id);
160 if (!timestamp) return next();
161 const question = questions.getByDate(timestamp);
162 if (!question) return next();
163 const form = await c.req.raw.json();
164 if (typeof form.text !== "string" || !Object.hasOwn(questions.types, form.type)) {
165 return questionFailure(c, 400, "Bad Request");
166 }
167 questions.updateByQmid(question.qmid, form.text, form.type);
168 return c.json({ success: true, message: "ok" });
169});
170app.get("/admin/q+a/:id", async (c, next) => {
171 const id = c.req.param("id");
172 const timestamp = questionIdToTimestamp(id);
173 if (!timestamp) return next();
174 const question = questions.getByDate(timestamp);
175 if (!question) return next();
176
177 let pendingInfo: null | questions.PendingData = null;
178 if (question.type === "pending") {
179 pendingInfo = JSON.parse(question.text) as questions.PendingData;
180 question.text = pendingInfo.prompt
181 .trim()
182 .split("\n")
183 .map((line) => (line.trim().length === 0 ? "" : `q: ${line.trim()}`))
184 .join("\n") + "\n\n";
185 question.type = "normal";
186 }
187
188 return view.serveCached(c, "q+a/editor", {
189 pendingInfo,
190 question,
191 });
192});
193
194app.get("/q+a/things/random", async (c) => {
195 c.res = await view.serveUncached(c, "q+a/things-random", {});
196});
197
198app.route("", lolZip.app);
199
200async function questionFailure(
201 c: Context,
202 status: ContentfulStatusCode,
203 message: string,
204 content?: string,
205) {
206 if (c.req.header("Accept")?.includes("application/json")) {
207 return c.json({ success: false, message, id: null }, { status });
208 }
209 return await view.serveUncached(c, "q+a/fail", {
210 error: message,
211 content,
212 });
213}
214
215import { serveAsset } from "#sitegen/assets";
216import * as view from "#sitegen/view";
217import { type Context, Hono } from "hono";
218import type { ContentfulStatusCode } from "hono/utils/http-status";
219import { adjectives, animals, colors, uniqueNamesGenerator } from "unique-names-generator";
220import { hasAdminToken } from "../admin.ts";
221import { formatQuestionId, questionIdToTimestamp } from "./format.ts";
222import { serveQuestionImage } from "./image.ts";
223import * as lolZip from "./lol.zip/backend.ts";
224import * as questions from "./models/questions.ts";