From 50b27cf498891c639352634cd48501c3f9531d21 Mon Sep 17 00:00:00 2001 From: pavel444-byte Date: Thu, 29 Oct 2026 16:32:55 +0500 Subject: [PATCH] Add moderator bots and editable MineGeo usernames --- .env.example | 6 + Dockerfile | 2 +- app/moderator-application/[token]/page.tsx | 32 ++ app/settings/page.tsx | 1 + components/AccountSettings.tsx | 29 ++ docs/moderator-bots.md | 9 + lib/account-api.ts | 11 +- lib/profile.ts | 2 +- package-lock.json | 351 +++++++++++++++++- package.json | 4 + scripts/moderator-bots.mjs | 92 +++++ scripts/supervise.mjs | 3 + scripts/test-moderator-applications-sql.mjs | 30 ++ shared/account.ts | 4 + shared/database.ts | 3 + shared/locales/ru.json | 9 +- .../20261029105929_moderator_applications.sql | 49 +++ .../20261029111310_fix_moderator_review.sql | 18 + tests/account.test.ts | 9 + 19 files changed, 654 insertions(+), 10 deletions(-) create mode 100644 app/moderator-application/[token]/page.tsx create mode 100644 docs/moderator-bots.md create mode 100644 scripts/moderator-bots.mjs create mode 100644 scripts/test-moderator-applications-sql.mjs create mode 100644 supabase/migrations/20261029105929_moderator_applications.sql create mode 100644 supabase/migrations/20261029111310_fix_moderator_review.sql diff --git a/.env.example b/.env.example index e0cc1a0..6ab354f 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,12 @@ MINEGEO_FORUM_URL= # Create a bot at https://discord.com/developers/applications and invite it to your server. # Channel IDs are copied with Discord Developer Mode enabled. Never use NEXT_PUBLIC_ here. DISCORD_BOT_TOKEN= +# Separate MineGeo Telegram bot. Run one bot process on the VPS. +TELEGRAM_BOT_TOKEN= +# Enable exactly one bot process on the VPS. +MINEGEO_RUN_BOTS=false +# Optional immutable Discord user ID for the initial admin binding. +DISCORD_MODERATOR_ADMIN_ID= DISCORD_MAPS_CHANNEL_ID= DISCORD_PARTIES_CHANNEL_ID= # Optional overrides for the read-only changelog (defaults to MineGeo news and release webhook). diff --git a/Dockerfile b/Dockerfile index 7c403a2..bddce7a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,7 +42,7 @@ COPY --from=production-dependencies --chown=node:node /app/node_modules ./node_m COPY --from=builder --chown=node:node /app/.next ./.next COPY --from=builder --chown=node:node /app/public ./public COPY --chown=node:node package.json tsconfig.json next.config.ts ./ -COPY --chown=node:node scripts/supervise.mjs scripts/runtime-config.mjs scripts/assets.mjs ./scripts/ +COPY --chown=node:node scripts/supervise.mjs scripts/runtime-config.mjs scripts/assets.mjs scripts/moderator-bots.mjs ./scripts/ COPY --chown=node:node worker ./worker COPY --chown=node:node shared ./shared COPY --chown=node:node lib ./lib diff --git a/app/moderator-application/[token]/page.tsx b/app/moderator-application/[token]/page.tsx new file mode 100644 index 0000000..3224a19 --- /dev/null +++ b/app/moderator-application/[token]/page.tsx @@ -0,0 +1,32 @@ +import { createHash } from "node:crypto"; +import { createClient } from "@supabase/supabase-js"; +import { redirect } from "next/navigation"; +import { user } from "@/lib/supabase"; +import { Header, Footer } from "@/components/Shell"; +export const metadata = { robots: { index: false, follow: false }, referrer: "no-referrer" as const }; + +function db() { + return createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SECRET_KEY!, { auth: { persistSession: false } }); +} +async function confirm(form: FormData) { + "use server"; + const token = String(form.get("token") ?? ""); + if (!/^[a-f0-9]{64}$/.test(token)) throw new Error("Invalid application link"); + const account = await user(); + if (!account) redirect(`/log-in?next=${encodeURIComponent(`/moderator-application/${token}`)}`); + const database = db(); + const { data: profile, error: profileError } = await database.from("profiles").select("username,account_status").eq("id", account.id).single(); + if (profileError || !profile || profile.account_status !== "active" || !profile.username) redirect(`/moderator-application/${token}?error=username`); + const hash = createHash("sha256").update(token).digest("hex"); + const { data, error } = await database.from("moderator_applications").update({ status: "pending", profile_id: account.id }).eq("confirmation_hash", hash).eq("requested_username", profile.username).eq("status", "confirming").gt("expires_at", new Date().toISOString()).select("id").maybeSingle(); + if (error || !data) redirect(`/moderator-application/${token}?error=link`); + redirect(`/moderator-application/${token}?submitted=1`); +} +export default async function ApplicationPage({ params, searchParams }: { params: Promise<{ token: string }>; searchParams: Promise<{ submitted?: string; error?: string }> }) { + const { token } = await params; + if (!/^[a-f0-9]{64}$/.test(token)) return
Invalid application link.
; + const account = await user(); + if (!account) redirect(`/log-in?next=${encodeURIComponent(`/moderator-application/${token}`)}`); + const { submitted, error } = await searchParams; + return <>

Moderator application

{submitted ?

Application sent for review.

: <>

Confirm that this MineGeo account belongs to you.

{error &&

{error === "username" ? "Set your username in account settings first." : "This link expired, was already used, or belongs to a different username. Start again in the bot."}

}
}