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 view.regenerate("q+a inbox");
88 const date = Question.create(
89 QuestionType.pending,
90 JSON.stringify(input),
91 input.date,
92 );
93 await sendSuccess(c, date);
94});
95async function sendSuccess(c: Context, date: Date) {
96 if (c.req.header("Accept")?.includes("application/json")) {
97 return c.json({
98 success: true,
99 message: "ok",
100 date: date.getTime(),
101 id: formatQuestionId(date),
102 }, { status: 200 });
103 }
104 c.res = await view.serve(c, "q+a/success", {
105 permalink: `https://paperclover.net/q+a/${formatQuestionId(date)}`,
106 });
107}
108// Question Permalink
109app.get("/q+a/:id", async (c, next) => {
110 // from deadname era, the seconds used to be in the url.
111 // this was removed so that the url can be crafted by hand.
112 let id = c.req.param("id");
113 if (id.length === 12 && /^\d+$/.test(id)) {
114 return c.redirect(`/q+a/${id.slice(0, 10)}`);
115 }
116 let image = false;
117 if (id.endsWith(".png")) {
118 image = true;
119 id = id.slice(0, -4);
120 }
121
122 const timestamp = questionIdToTimestamp(id);
123 if (!timestamp) return next();
124 const question = Question.getByDate(timestamp);
125 if (!question) return next();
126
127 if (image) {
128 return getQuestionImage(question, c.req.method === "HEAD");
129 }
130 if (c.req.header("User-Agent")?.includes("clover-qa-image")) {
131 return view.serve(c, "q+a/image-embed", { question });
132 }
133 return view.serve(c, "q+a/permalink", { question });
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.serve(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 = Question.getByDate(timestamp);
148 if (!question) return next();
149 const deleteFull = c.req.header("X-Delete-Full") === "true";
150 if (deleteFull) {
151 Question.deleteByQmid(question.qmid);
152 } else {
153 Question.rejectByQmid(question.qmid);
154 }
155 view.regenerate("q+a");
156 return c.json({ success: true, message: "ok" });
157});
158app.patch("/admin/q+a/:id", async (c, next) => {
159 const id = c.req.param("id");
160 const timestamp = questionIdToTimestamp(id);
161 if (!timestamp) return next();
162 const question = Question.getByDate(timestamp);
163 if (!question) return next();
164 const form = await c.req.raw.json();
165 if (typeof form.text !== "string" || typeof form.type !== "number") {
166 return questionFailure(c, 400, "Bad Request");
167 }
168 Question.updateByQmid(question.qmid, form.text, form.type);
169 view.regenerate("q+a");
170 return c.json({ success: true, message: "ok" });
171});
172app.get("/admin/q+a/:id", async (c, next) => {
173 const id = c.req.param("id");
174 const timestamp = questionIdToTimestamp(id);
175 if (!timestamp) return next();
176 const question = Question.getByDate(timestamp);
177 if (!question) return next();
178
179 let pendingInfo: null | PendingQuestionData = null;
180 if (question.type === QuestionType.pending) {
181 pendingInfo = JSON.parse(question.text) as PendingQuestionData;
182 question.text = pendingInfo.prompt
183 .trim()
184 .split("\n")
185 .map((line) => (line.trim().length === 0 ? "" : `q: ${line.trim()}`))
186 .join("\n") + "\n\n";
187 question.type = QuestionType.normal;
188 }
189
190 return view.serve(c, "q+a/editor", {
191 pendingInfo,
192 question,
193 });
194});
195
196app.get("/q+a/things/random", async (c) => {
197 c.res = await view.serve(c, "q+a/things-random", {});
198});
199
200app.route("", require("./lol.zip/backend.ts").app);
201
202async function questionFailure(
203 c: Context,
204 status: ContentfulStatusCode,
205 message: string,
206 content?: string,
207) {
208 if (c.req.header("Accept")?.includes("application/json")) {
209 return c.json({ success: false, message, id: null }, { status });
210 }
211 return await view.serve(c, "q+a/fail", {
212 error: message,
213 content,
214 });
215}
216
217import { serveAsset } from "#sitegen/assets";
218import * as view from "#sitegen/view";
219import { type Context, Hono } from "hono";
220import type { ContentfulStatusCode } from "hono/utils/http-status";
221import { adjectives, animals, colors, uniqueNamesGenerator } from "unique-names-generator";
222import { hasAdminToken } from "../admin.ts";
223import { formatQuestionId, questionIdToTimestamp } from "./format.ts";
224import { getQuestionImage } from "./image.ts";
225import type { PendingQuestionData } from "./models/PendingQuestion.ts";
226import { Question, QuestionType } from "./models/Question.ts";