Autoban on repeated FC + admin FC search Closes #2024
Some checks failed
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

This commit is contained in:
Kalle
2025-03-23 19:14:57 +02:00
parent d6f4cf577b
commit 1e905e2941
5 changed files with 166 additions and 6 deletions

View File

@@ -0,0 +1,6 @@
import { z } from "zod";
import { friendCode } from "~/utils/zod";
export const adminActionSearchParamsSchema = z.object({
friendCode,
});

View File

@@ -1,10 +1,17 @@
import type { LoaderFunction } from "@remix-run/node";
import type { LoaderFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { getUserId, isImpersonating } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { isMod } from "~/permissions";
import { parseSafeSearchParams } from "~/utils/remix.server";
import { adminActionSearchParamsSchema } from "../admin-schemas";
export const loader: LoaderFunction = async ({ request }) => {
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await getUserId(request);
const parsedSearchParams = parseSafeSearchParams({
request,
schema: adminActionSearchParamsSchema,
});
if (process.env.NODE_ENV === "production" && !isMod(user)) {
throw redirect("/");
@@ -12,5 +19,10 @@ export const loader: LoaderFunction = async ({ request }) => {
return {
isImpersonating: await isImpersonating(request),
friendCodeSearchUsers: parsedSearchParams.success
? await UserRepository.findByFriendCode(
parsedSearchParams.data.friendCode,
)
: [],
};
};

View File

@@ -1,22 +1,32 @@
import type { MetaFunction } from "@remix-run/node";
import {
Form,
Link,
useFetcher,
useLoaderData,
useNavigation,
useSearchParams,
} from "@remix-run/react";
import * as React from "react";
import { Avatar } from "~/components/Avatar";
import { Button } from "~/components/Button";
import { Catcher } from "~/components/Catcher";
import { Input } from "~/components/Input";
import { Main } from "~/components/Main";
import { NewTabs } from "~/components/NewTabs";
import { SubmitButton } from "~/components/SubmitButton";
import { UserSearch } from "~/components/UserSearch";
import { SearchIcon } from "~/components/icons/Search";
import { useUser } from "~/features/auth/core/user";
import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants";
import { isAdmin, isMod } from "~/permissions";
import { metaTags } from "~/utils/remix";
import { SEED_URL, STOP_IMPERSONATING_URL, impersonateUrl } from "~/utils/urls";
import {
SEED_URL,
STOP_IMPERSONATING_URL,
impersonateUrl,
userPage,
} from "~/utils/urls";
import { action } from "../actions/admin.server";
import { loader } from "../loaders/admin.server";
@@ -30,10 +40,78 @@ export const meta: MetaFunction = (args) => {
};
export default function AdminPage() {
return (
<Main>
<NewTabs
tabs={[
{
label: "Actions",
},
{
label: "Friend code look-up",
},
]}
content={[
{
key: "actions",
element: <AdminActions />,
},
{
key: "friend-code-look-up",
element: <FriendCodeLookUp />,
},
]}
/>
</Main>
);
}
function FriendCodeLookUp() {
const data = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const [friendCode, setFriendCode] = React.useState(
searchParams.get("friendCode") ?? "",
);
const fetcher = useFetcher();
return (
<div>
<div className="stack md horizontal justify-center">
<Input
placeholder="1234-5678-9101"
name="friendCode"
value={friendCode}
onChange={(e) => setFriendCode(e.target.value)}
/>
<SubmitButton
state={fetcher.state}
icon={<SearchIcon />}
onClick={() => setSearchParams({ friendCode })}
>
Search
</SubmitButton>
</div>
<div className="stack lg">
{data.friendCodeSearchUsers?.map((user) => (
<Link
key={user.id}
to={userPage(user)}
className="stack horizontal sm text-main-forced items-center"
>
<Avatar user={user} size="sm" />
{user.username}
</Link>
))}
</div>
</div>
);
}
function AdminActions() {
const user = useUser();
return (
<Main className="stack lg">
<div className="stack lg">
{process.env.NODE_ENV !== "production" && <Seed />}
{isMod(user) ? <LinkPlayer /> : null}
@@ -51,7 +129,7 @@ export default function AdminPage() {
{isMod(user) ? <UnbanUser /> : null}
{isAdmin(user) ? <RefreshPlusTiers /> : null}
{isAdmin(user) ? <CleanUp /> : null}
</Main>
</div>
);
}

View File

@@ -1,7 +1,9 @@
import type { ActionFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { sql } from "~/db/sql";
import * as AdminRepository from "~/features/admin/AdminRepository.server";
import { requireUser } from "~/features/auth/core/user.server";
import { refreshBannedCache } from "~/features/ban/core/banned.server";
import { currentSeason } from "~/features/mmr/season";
import * as QRepository from "~/features/sendouq/QRepository.server";
import { giveTrust } from "~/features/tournament/queries/giveTrust.server";
@@ -9,7 +11,11 @@ import * as UserRepository from "~/features/user-page/UserRepository.server";
import invariant from "~/utils/invariant";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { SENDOUQ_LOOKING_PAGE, SENDOUQ_PREPARING_PAGE } from "~/utils/urls";
import {
SENDOUQ_LOOKING_PAGE,
SENDOUQ_PREPARING_PAGE,
SUSPENDED_PAGE,
} from "~/utils/urls";
import { FULL_GROUP_SIZE, JOIN_CODE_SEARCH_PARAM_KEY } from "../q-constants";
import { frontPageSchema } from "../q-schemas.server";
import { userCanJoinQueueAt } from "../q-utils";
@@ -88,12 +94,29 @@ export const action: ActionFunction = async ({ request }) => {
"Friend code already set",
);
const isTakenFriendCode = (
await UserRepository.allCurrentFriendCodes()
).has(data.friendCode);
await UserRepository.insertFriendCode({
userId: user.id,
friendCode: data.friendCode,
submitterUserId: user.id,
});
if (isTakenFriendCode) {
await AdminRepository.banUser({
userId: user.id,
banned: 1,
bannedReason:
"[automatic ban] This friend code is already in use by some other account. Please contact staff on our Discord helpdesk for resolution including merging accounts.",
});
refreshBannedCache();
throw redirect(SUSPENDED_PAGE);
}
return null;
}
default: {

View File

@@ -253,6 +253,15 @@ export function findByCustomUrl(customUrl: string) {
.executeTakeFirst();
}
export function findByFriendCode(friendCode: string) {
return db
.selectFrom("UserFriendCode")
.innerJoin("User", "User.id", "UserFriendCode.userId")
.select([...COMMON_USER_FIELDS])
.where("UserFriendCode.friendCode", "=", friendCode)
.execute();
}
export function findBannedStatusByUserId(userId: number) {
return db
.selectFrom("User")
@@ -553,6 +562,36 @@ export async function currentFriendCodeByUserId(userId: number) {
.executeTakeFirst();
}
let cachedFriendCodes: Set<string> | null = null;
export async function allCurrentFriendCodes() {
if (cachedFriendCodes) {
return cachedFriendCodes;
}
const allFriendCodes = await db
.selectFrom("UserFriendCode")
.select(["UserFriendCode.friendCode", "UserFriendCode.userId"])
.orderBy("UserFriendCode.createdAt desc")
.execute();
const seenUserIds = new Set<number>();
const friendCodes = new Set<string>();
for (const row of allFriendCodes) {
if (seenUserIds.has(row.userId)) {
continue;
}
seenUserIds.add(row.userId);
friendCodes.add(row.friendCode);
}
cachedFriendCodes = friendCodes;
return friendCodes;
}
export async function inGameNameByUserId(userId: number) {
return (
await db
@@ -564,6 +603,8 @@ export async function inGameNameByUserId(userId: number) {
}
export function insertFriendCode(args: TablesInsertable["UserFriendCode"]) {
cachedFriendCodes?.add(args.friendCode);
return db.insertInto("UserFriendCode").values(args).execute();
}