Add Telegram moderator application mini app
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 1m41s
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 1m41s
This commit is contained in:
80
lib/moderator-applications.ts
Normal file
80
lib/moderator-applications.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import "server-only";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { admin } from "@/lib/supabase";
|
||||
|
||||
const clean = (value: unknown) =>
|
||||
String(value ?? "")
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, " ")
|
||||
.slice(0, 80);
|
||||
|
||||
export type ModeratorApplicationResult =
|
||||
| { ok: true; url: string }
|
||||
| { ok: false; message: string };
|
||||
|
||||
/**
|
||||
* Creates a moderator application for a confirmed platform identity (Telegram
|
||||
* or Discord user verified by the caller). The applicant still has to open the
|
||||
* returned link while signed in to the matching MineGeo account.
|
||||
*/
|
||||
export async function createModeratorApplication(input: {
|
||||
platform: "telegram" | "discord";
|
||||
platformUserId: string;
|
||||
platformUsername?: string | undefined;
|
||||
requestedUsername: string;
|
||||
origin: string;
|
||||
}): Promise<ModeratorApplicationResult> {
|
||||
const username = input.requestedUsername.trim().toLowerCase();
|
||||
if (!/^[a-z0-9_]{3,32}$/.test(username))
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
"Use your MineGeo username (3–32 letters, numbers or underscores). Set it in MineGeo settings first.",
|
||||
};
|
||||
const database = admin();
|
||||
const { data: allowed, error: rateError } = await database.rpc("check_rate", {
|
||||
p_key: `mod-apply:${input.platform}:${input.platformUserId}`,
|
||||
p_limit: 5,
|
||||
p_seconds: 3600,
|
||||
});
|
||||
if (rateError || !allowed)
|
||||
return { ok: false, message: "Too many attempts. Try again later." };
|
||||
const { data: profile, error } = await database
|
||||
.from("profiles")
|
||||
.select("id,role,account_status")
|
||||
.eq("username", username)
|
||||
.maybeSingle();
|
||||
if (error) return { ok: false, message: "MineGeo is unavailable. Try again later." };
|
||||
if (!profile || profile.account_status !== "active")
|
||||
return {
|
||||
ok: false,
|
||||
message: "MineGeo username not found. Check the username in your account settings.",
|
||||
};
|
||||
if (profile.role !== "contributor")
|
||||
return { ok: false, message: "This account already has staff permissions." };
|
||||
const { data: existing } = await database
|
||||
.from("moderator_applications")
|
||||
.select("id")
|
||||
.eq("platform", input.platform)
|
||||
.eq("platform_user_id", input.platformUserId)
|
||||
.eq("status", "pending")
|
||||
.limit(1);
|
||||
if (existing?.length)
|
||||
return { ok: false, message: "You already have an application waiting for review." };
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const hash = createHash("sha256").update(token).digest("hex");
|
||||
const insert = await database.from("moderator_applications").insert({
|
||||
platform: input.platform,
|
||||
platform_user_id: input.platformUserId,
|
||||
platform_username: clean(input.platformUsername),
|
||||
requested_username: username,
|
||||
confirmation_hash: hash,
|
||||
});
|
||||
if (insert.error) {
|
||||
console.error("Moderator application insert failed", insert.error);
|
||||
return { ok: false, message: "MineGeo is unavailable. Try again later." };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
url: `${new URL(input.origin).origin}/moderator-application/${token}`,
|
||||
};
|
||||
}
|
||||
57
lib/telegram-webapp.ts
Normal file
57
lib/telegram-webapp.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
export interface TelegramInitDataUser {
|
||||
id: string;
|
||||
username?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates Telegram Mini App initData against the bot token.
|
||||
* https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
||||
*/
|
||||
export function verifyTelegramInitData(
|
||||
initData: string,
|
||||
botToken: string,
|
||||
maxAgeSeconds = 86400,
|
||||
): TelegramInitDataUser | null {
|
||||
if (!initData || initData.length > 4096 || !botToken) return null;
|
||||
const params = new URLSearchParams(initData);
|
||||
if (new Set(params.keys()).size !== [...params.keys()].length) return null;
|
||||
const hash = params.get("hash");
|
||||
if (!hash || !/^[a-f0-9]{64}$/.test(hash)) return null;
|
||||
params.delete("hash");
|
||||
const checkString = [...params.keys()]
|
||||
.sort()
|
||||
.map((key) => `${key}=${params.get(key)}`)
|
||||
.join("\n");
|
||||
const secret = createHmac("sha256", "WebAppData").update(botToken).digest();
|
||||
const expected = createHmac("sha256", secret).update(checkString).digest("hex");
|
||||
const valid =
|
||||
hash.length === expected.length &&
|
||||
timingSafeEqual(Buffer.from(hash), Buffer.from(expected));
|
||||
if (!valid) return null;
|
||||
const authDate = Number(params.get("auth_date"));
|
||||
if (!Number.isFinite(authDate) || Math.abs(Date.now() / 1000 - authDate) > maxAgeSeconds)
|
||||
return null;
|
||||
const raw = params.get("user");
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const user = JSON.parse(raw) as {
|
||||
id?: unknown;
|
||||
username?: unknown;
|
||||
first_name?: unknown;
|
||||
last_name?: unknown;
|
||||
};
|
||||
if (typeof user.id !== "number" && typeof user.id !== "string") return null;
|
||||
return {
|
||||
id: String(user.id),
|
||||
...(typeof user.username === "string" ? { username: user.username } : {}),
|
||||
...(typeof user.first_name === "string" ? { firstName: user.first_name } : {}),
|
||||
...(typeof user.last_name === "string" ? { lastName: user.last_name } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user