2025-06-10 01:13:59 -07:00
|
|
|
const PROXYCHECK_API_KEY = process.env.PROXYCHECK_API_KEY;
|
|
|
|
|
2025-06-08 15:12:04 -07:00
|
|
|
export const app = new Hono();
|
|
|
|
|
2025-06-10 01:13:59 -07:00
|
|
|
// 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("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(
|
|
|
|
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 });
|
|
|
|
}
|
2025-06-15 11:35:28 -07:00
|
|
|
c.res = await renderView(c, "q+a/success", {
|
2025-06-10 01:13:59 -07:00
|
|
|
permalink: `https://paperclover.net/q+a/${formatQuestionId(date)}`,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
// Question Permalink
|
2025-06-15 11:35:28 -07:00
|
|
|
app.get("/q+a/:id", async (c, next) => {
|
2025-06-10 01:13:59 -07:00
|
|
|
// 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();
|
|
|
|
|
2025-06-15 13:11:21 -07:00
|
|
|
if (image) {
|
|
|
|
return getQuestionImage(question, c.req.method === "HEAD");
|
|
|
|
}
|
2025-06-10 01:13:59 -07:00
|
|
|
return renderView(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) => {
|
2025-06-15 11:35:28 -07:00
|
|
|
return renderView(c, "q+a/backend-inbox", {});
|
2025-06-10 01:13:59 -07:00
|
|
|
});
|
|
|
|
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") {
|
|
|
|
return questionFailure(c, 400, "Bad Request");
|
|
|
|
}
|
|
|
|
Question.updateByQmid(question.qmid, form.text, form.type);
|
|
|
|
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 renderView(c, "q+a/editor", {
|
|
|
|
pendingInfo,
|
|
|
|
question,
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
app.get("/q+a/things/random", async (c) => {
|
|
|
|
c.res = await renderView(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 renderView(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 {
|
|
|
|
PendingQuestion,
|
|
|
|
PendingQuestionData,
|
|
|
|
} from "./models/PendingQuestion.ts";
|
|
|
|
import { Question, QuestionType } from "./models/Question.ts";
|
|
|
|
import { renderView } from "#sitegen/view";
|
2025-06-15 13:11:21 -07:00
|
|
|
import { getQuestionImage } from "./image.tsx";
|
2025-06-10 01:13:59 -07:00
|
|
|
import { formatQuestionId, questionIdToTimestamp } from "./format.ts";
|