Timed bans feature

This commit is contained in:
Kalle 2024-04-11 20:59:05 +03:00
parent 6d68eecaae
commit 7f25bbcd78
22 changed files with 459 additions and 189 deletions

View File

@ -585,7 +585,9 @@ export interface UserMapModePreferences {
}
export interface User {
/** 1 = permabanned, timestamp = ban active till then */
banned: Generated<number | null>;
bannedReason: string | null;
bio: string | null;
commissionsOpen: Generated<number | null>;
commissionText: string | null;

View File

@ -114,3 +114,30 @@ export function forcePatron(args: {
.where("User.id", "=", args.id)
.execute();
}
export function banUser({
userId,
banned,
bannedReason,
}: {
userId: number;
banned: 1 | Date;
bannedReason: string | null;
}) {
return db
.updateTable("User")
.set({
banned: banned === 1 ? banned : dateToDatabaseTimestamp(banned),
bannedReason,
})
.where("User.id", "=", userId)
.execute();
}
export function unbanUser(userId: number) {
return db
.updateTable("User")
.set({ banned: 0 })
.where("User.id", "=", userId)
.execute();
}

View File

@ -0,0 +1,149 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { z } from "zod";
import * as AdminRepository from "~/features/admin/AdminRepository.server";
import { makeArtist } from "~/features/art/queries/makeArtist.server";
import { requireUserId } from "~/features/auth/core/user.server";
import { isAdmin, isMod } from "~/permissions";
import { parseRequestFormData, validate } from "~/utils/remix";
import { assertUnreachable } from "~/utils/types";
import { _action, actualNumber } from "~/utils/zod";
import { plusTiersFromVotingAndLeaderboard } from "../core/plus-tier.server";
import { refreshBannedCache } from "~/features/ban/core/banned.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const data = await parseRequestFormData({
request,
schema: adminActionSchema,
});
const user = await requireUserId(request);
switch (data._action) {
case "MIGRATE": {
validate(isAdmin(user), "Admin needed", 401);
await AdminRepository.migrate({
oldUserId: data["old-user"],
newUserId: data["new-user"],
});
break;
}
case "REFRESH": {
validate(isAdmin(user));
await AdminRepository.replacePlusTiers(
await plusTiersFromVotingAndLeaderboard(),
);
break;
}
case "FORCE_PATRON": {
validate(isAdmin(user), "Admin needed", 401);
await AdminRepository.forcePatron({
id: data["user"],
patronSince: new Date(),
patronTier: data.patronTier,
patronTill: new Date(data.patronTill),
});
break;
}
case "CLEAN_UP": {
validate(isAdmin(user), "Admin needed", 401);
// on purpose sync
AdminRepository.cleanUp();
break;
}
case "ARTIST": {
validate(isMod(user), "Mod needed", 401);
makeArtist(data["user"]);
break;
}
case "VIDEO_ADDER": {
validate(isMod(user), "Mod needed", 401);
await AdminRepository.makeVideoAdderByUserId(data["user"]);
break;
}
case "LINK_PLAYER": {
validate(isMod(user), "Mod needed", 401);
await AdminRepository.linkUserAndPlayer({
userId: data["user"],
playerId: data.playerId,
});
break;
}
case "BAN_USER": {
validate(isAdmin(user), "Admin needed", 401);
await AdminRepository.banUser({
bannedReason: data.reason ?? null,
userId: data["user"],
banned: data.duration ? new Date(data.duration) : 1,
});
refreshBannedCache();
break;
}
case "UNBAN_USER": {
validate(isAdmin(user), "Admin needed", 401);
await AdminRepository.unbanUser(data["user"]);
refreshBannedCache();
break;
}
default: {
assertUnreachable(data);
}
}
return { ok: true };
};
export const adminActionSchema = z.union([
z.object({
_action: _action("MIGRATE"),
"old-user": z.preprocess(actualNumber, z.number().positive()),
"new-user": z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("REFRESH"),
}),
z.object({
_action: _action("CLEAN_UP"),
}),
z.object({
_action: _action("FORCE_PATRON"),
user: z.preprocess(actualNumber, z.number().positive()),
patronTier: z.preprocess(actualNumber, z.number()),
patronTill: z.string(),
}),
z.object({
_action: _action("VIDEO_ADDER"),
user: z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("ARTIST"),
user: z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("LINK_PLAYER"),
user: z.preprocess(actualNumber, z.number().positive()),
playerId: z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("BAN_USER"),
user: z.preprocess(actualNumber, z.number().positive()),
reason: z.string().nullish(),
duration: z.string().nullish(),
}),
z.object({
_action: _action("UNBAN_USER"),
user: z.preprocess(actualNumber, z.number().positive()),
}),
]);

View File

@ -1,35 +0,0 @@
import { _action, actualNumber } from "~/utils/zod";
import { z } from "zod";
export const adminActionSchema = z.union([
z.object({
_action: _action("MIGRATE"),
"old-user": z.preprocess(actualNumber, z.number().positive()),
"new-user": z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("REFRESH"),
}),
z.object({
_action: _action("CLEAN_UP"),
}),
z.object({
_action: _action("FORCE_PATRON"),
user: z.preprocess(actualNumber, z.number().positive()),
patronTier: z.preprocess(actualNumber, z.number()),
patronTill: z.string(),
}),
z.object({
_action: _action("VIDEO_ADDER"),
user: z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("ARTIST"),
user: z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("LINK_PLAYER"),
user: z.preprocess(actualNumber, z.number().positive()),
playerId: z.preprocess(actualNumber, z.number().positive()),
}),
]);

View File

@ -0,0 +1,16 @@
import type { LoaderFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { getUserId, isImpersonating } from "~/features/auth/core/user.server";
import { isMod } from "~/permissions";
export const loader: LoaderFunction = async ({ request }) => {
const user = await getUserId(request);
if (process.env.NODE_ENV === "production" && !isMod(user)) {
throw redirect("/");
}
return {
isImpersonating: await isImpersonating(request),
};
};

View File

@ -6,7 +6,7 @@ import { db } from "~/db/sql";
import MockDate from "mockdate";
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import type { adminActionSchema } from "../admin-schemas.server";
import type { adminActionSchema } from "../actions/admin.server";
const PlusVoting = suite("Plus voting");

View File

@ -1,9 +1,4 @@
import type {
ActionFunctionArgs,
LoaderFunction,
MetaFunction,
} from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import type { MetaFunction } from "@remix-run/node";
import {
Form,
useFetcher,
@ -16,119 +11,20 @@ import { Catcher } from "~/components/Catcher";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { UserSearch } from "~/components/UserSearch";
import * as AdminRepository from "~/features/admin/AdminRepository.server";
import { useUser } from "~/features/auth/core/user";
import {
getUserId,
isImpersonating,
requireUserId,
} from "~/features/auth/core/user.server";
import { isAdmin, isMod } from "~/permissions";
import {
parseRequestFormData,
validate,
type SendouRouteHandle,
} from "~/utils/remix";
import { type SendouRouteHandle } from "~/utils/remix";
import { makeTitle } from "~/utils/strings";
import { assertUnreachable } from "~/utils/types";
import { SEED_URL, STOP_IMPERSONATING_URL, impersonateUrl } from "~/utils/urls";
import { adminActionSchema } from "../admin-schemas.server";
import { plusTiersFromVotingAndLeaderboard } from "../core/plus-tier.server";
import { makeArtist } from "~/features/art/queries/makeArtist.server";
import { action } from "../actions/admin.server";
import { loader } from "../loaders/admin.server";
export { action, loader };
export const meta: MetaFunction = () => {
return [{ title: makeTitle("Admin page") }];
};
export const action = async ({ request }: ActionFunctionArgs) => {
const data = await parseRequestFormData({
request,
schema: adminActionSchema,
});
const user = await requireUserId(request);
switch (data._action) {
case "MIGRATE": {
validate(isAdmin(user), "Admin needed", 401);
await AdminRepository.migrate({
oldUserId: data["old-user"],
newUserId: data["new-user"],
});
break;
}
case "REFRESH": {
validate(isAdmin(user));
await AdminRepository.replacePlusTiers(
await plusTiersFromVotingAndLeaderboard(),
);
break;
}
case "FORCE_PATRON": {
validate(isAdmin(user), "Admin needed", 401);
await AdminRepository.forcePatron({
id: data["user"],
patronSince: new Date(),
patronTier: data.patronTier,
patronTill: new Date(data.patronTill),
});
break;
}
case "CLEAN_UP": {
validate(isAdmin(user), "Admin needed", 401);
// on purpose sync
AdminRepository.cleanUp();
break;
}
case "ARTIST": {
validate(isMod(user), "Mod needed", 401);
makeArtist(data["user"]);
break;
}
case "VIDEO_ADDER": {
validate(isMod(user), "Mod needed", 401);
await AdminRepository.makeVideoAdderByUserId(data["user"]);
break;
}
case "LINK_PLAYER": {
validate(isMod(user), "Mod needed", 401);
await AdminRepository.linkUserAndPlayer({
userId: data["user"],
playerId: data.playerId,
});
break;
}
default: {
assertUnreachable(data);
}
}
return { ok: true };
};
interface AdminPageLoaderData {
isImpersonating: boolean;
}
export const loader: LoaderFunction = async ({ request }) => {
const user = await getUserId(request);
if (process.env.NODE_ENV === "production" && !isMod(user)) {
throw redirect("/");
}
return json<AdminPageLoaderData>({
isImpersonating: await isImpersonating(request),
});
};
export const handle: SendouRouteHandle = {
navItemName: "admin",
};
@ -149,6 +45,8 @@ export default function AdminPage() {
) : null}
{isAdmin(user) ? <MigrateUser /> : null}
{isAdmin(user) ? <ForcePatron /> : null}
{isAdmin(user) ? <BanUser /> : null}
{isAdmin(user) ? <UnbanUser /> : null}
{isAdmin(user) ? <RefreshPlusTiers /> : null}
{isAdmin(user) ? <CleanUp /> : null}
</Main>
@ -157,7 +55,7 @@ export default function AdminPage() {
function Impersonate() {
const [userId, setUserId] = React.useState<number>();
const { isImpersonating } = useLoaderData<AdminPageLoaderData>();
const { isImpersonating } = useLoaderData<typeof loader>();
return (
<Form
@ -340,6 +238,56 @@ function ForcePatron() {
);
}
function BanUser() {
const fetcher = useFetcher();
return (
<fetcher.Form className="stack md" method="post">
<h2 className="text-warning">Ban user</h2>
<div className="stack horizontal md">
<div>
<label>User</label>
<UserSearch inputName="user" />
</div>
<div>
<label>Banned till</label>
<input name="duration" type="datetime-local" />
</div>
<div>
<label>Reason</label>
<input name="reason" type="text" />
</div>
</div>
<div className="stack horizontal md">
<SubmitButton type="submit" _action="BAN_USER" state={fetcher.state}>
Save
</SubmitButton>
</div>
</fetcher.Form>
);
}
function UnbanUser() {
const fetcher = useFetcher();
return (
<fetcher.Form className="stack md" method="post">
<h2 className="text-warning">Unban user</h2>
<div>
<label>User</label>
<UserSearch inputName="user" />
</div>
<div className="stack horizontal md">
<SubmitButton type="submit" _action="UNBAN_USER" state={fetcher.state}>
Save
</SubmitButton>
</div>
</fetcher.Form>
);
}
function RefreshPlusTiers() {
const fetcher = useFetcher();

View File

@ -2,9 +2,13 @@ import type { User } from "~/db/types";
import { IMPERSONATED_SESSION_KEY, SESSION_KEY } from "./authenticator.server";
import { authSessionStorage } from "./session.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { userIsBanned } from "~/features/ban/core/banned.server";
import { redirect } from "@remix-run/node";
import { SUSPENDED_PAGE } from "~/utils/urls";
export async function getUserId(
request: Request,
redirectIfBanned = true,
): Promise<Pick<User, "id"> | undefined> {
const session = await authSessionStorage.getSession(
request.headers.get("Cookie"),
@ -15,11 +19,13 @@ export async function getUserId(
if (!userId) return;
if (userIsBanned(userId) && redirectIfBanned) throw redirect(SUSPENDED_PAGE);
return { id: userId };
}
export async function getUser(request: Request) {
const userId = (await getUserId(request))?.id;
export async function getUser(request: Request, redirectIfBanned = true) {
const userId = (await getUserId(request, redirectIfBanned))?.id;
if (!userId) return;

View File

@ -0,0 +1,26 @@
import { cache, syncCached } from "~/utils/cache.server";
import { allBannedUsers } from "../queries/allBannedUsers.server";
import { databaseTimestampToDate } from "~/utils/dates";
const BANNED_USERS_CACHE_KEY = "bannedUsers";
export function cachedBannedUsers() {
return syncCached(BANNED_USERS_CACHE_KEY, () => allBannedUsers());
}
export function userIsBanned(userId: number) {
const banStatus = cachedBannedUsers().get(userId);
if (!banStatus?.banned) return false;
if (banStatus.banned === 1) return true;
const banExpiresAt = databaseTimestampToDate(banStatus.banned);
return banExpiresAt > new Date();
}
export function refreshBannedCache() {
cache.delete(BANNED_USERS_CACHE_KEY);
cachedBannedUsers();
}

View File

@ -0,0 +1,30 @@
import { redirect, type LoaderFunctionArgs } from "@remix-run/node";
import { cachedBannedUsers, userIsBanned } from "../core/banned.server";
import { authSessionStorage } from "~/features/auth/core/session.server";
import {
IMPERSONATED_SESSION_KEY,
SESSION_KEY,
} from "~/features/auth/core/authenticator.server";
import type { Nullish } from "~/utils/types";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await getUserIdEvenIfBanned(request);
if (!userId || !userIsBanned(userId)) return redirect("/");
const bannedStatus = cachedBannedUsers().get(userId)!;
return {
banned: bannedStatus.banned,
reason: bannedStatus.bannedReason,
};
};
async function getUserIdEvenIfBanned(
request: Request,
): Promise<Nullish<number>> {
const session = await authSessionStorage.getSession(
request.headers.get("Cookie"),
);
return session.get(IMPERSONATED_SESSION_KEY) ?? session.get(SESSION_KEY);
}

View File

@ -0,0 +1,29 @@
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
const stm = sql.prepare(/*sql */ `
select
"User"."id" as "userId",
"User"."banned",
"User"."bannedReason"
from
"User"
where
"User"."banned" != 0
`);
type BannedUserRow = Pick<Tables["User"], "banned" | "bannedReason"> & {
userId: number;
};
export function allBannedUsers() {
const rows = stm.all() as Array<BannedUserRow>;
const result: Map<number, BannedUserRow> = new Map();
for (const row of rows) {
result.set(row.userId, row);
}
return result;
}

View File

@ -0,0 +1,39 @@
import { Main } from "~/components/Main";
import { useLoaderData } from "@remix-run/react";
import { databaseTimestampToDate } from "~/utils/dates";
import { loader } from "../loaders/suspended.server";
export { loader };
export default function SuspendedPage() {
const data = useLoaderData<typeof loader>();
const ends = (() => {
if (!data.banned || data.banned === 1) return null;
return databaseTimestampToDate(data.banned);
})();
return (
<Main>
<h2>Account suspended</h2>
{data.reason ? <div>Reason: {data.reason}</div> : null}
{ends ? (
<div suppressHydrationWarning>
Ends:{" "}
{ends.toLocaleString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
})}
</div>
) : (
<div>
Ends: <i>no end time set</i>
</div>
)}
</Main>
);
}

View File

@ -11,6 +11,7 @@ import type { LookingGroupWithInviteCode } from "./q-types";
import { nanoid } from "nanoid";
import { INVITE_CODE_LENGTH } from "~/constants";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { userIsBanned } from "../ban/core/banned.server";
export function mapModePreferencesByGroupId(groupId: number) {
return db
@ -276,8 +277,8 @@ export function deletePrivateUserNote({
.execute();
}
export function usersThatTrusted(userId: number) {
return db
export async function usersThatTrusted(userId: number) {
const rows = await db
.selectFrom("TeamMember")
.innerJoin("User", "User.id", "TeamMember.userId")
.innerJoin("UserFriendCode", "UserFriendCode.userId", "User.id")
@ -292,16 +293,18 @@ export function usersThatTrusted(userId: number) {
.where("TeamMember.userId", "=", userId),
),
)
.where("User.banned", "=", 0)
.union((eb) =>
eb
.selectFrom("TrustRelationship")
.innerJoin("User", "User.id", "TrustRelationship.trustGiverUserId")
.innerJoin("UserFriendCode", "UserFriendCode.userId", "User.id")
.select(COMMON_USER_FIELDS)
.where("TrustRelationship.trustReceiverUserId", "=", userId)
.where("User.banned", "=", 0),
.where("TrustRelationship.trustReceiverUserId", "=", userId),
)
.orderBy("User.discordName asc")
.execute();
const rowsWithoutBanned = rows.filter((row) => !userIsBanned(row.id));
return rowsWithoutBanned;
}

View File

@ -54,6 +54,7 @@ export function findByIdentifier(identifier: string) {
"User.youtubeId",
"User.favoriteBadgeId",
"User.banned",
"User.bannedReason",
"User.commissionText",
"User.commissionsOpen",
"User.patronTier",
@ -97,7 +98,6 @@ export function findLeanById(id: number) {
"User.isVideoAdder",
"User.patronTier",
"User.favoriteBadgeId",
"User.banned",
"User.languages",
"PlusTier.tier as plusTier",
])

View File

@ -33,6 +33,8 @@ import * as BuildRepository from "~/features/builds/BuildRepository.server";
import { countArtByUserId } from "~/features/art/queries/countArtByUserId.server";
import { findVods } from "~/features/vods/queries/findVods.server";
import { userParamsSchema } from "../user-page-schemas.server";
import { userIsBanned } from "~/features/ban/core/banned.server";
import { databaseTimestampToDate } from "~/utils/dates";
import "~/styles/u.css";
@ -79,7 +81,10 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
discordUniqueName: user.showDiscordUniqueName
? user.discordUniqueName
: null,
banned: isAdmin(loggedInUser) ? user.banned : undefined,
banned:
isAdmin(loggedInUser) && userIsBanned(user.id)
? { banned: user.banned, bannedReason: user.bannedReason }
: undefined,
css: canAddCustomizedColorsToUserProfile(user) ? user.css : undefined,
badges: await BadgeRepository.findByOwnerId({
userId: user.id,
@ -143,7 +148,7 @@ export default function UserPageLayout() {
</SubNavLink>
)}
</SubNav>
{data.banned ? <div className="text-warning">Banned</div> : null}
<BannedInfo />
<Outlet />
</Main>
);
@ -175,3 +180,40 @@ function useReplaceWithCustomUrl() {
);
}, [location, data.customUrl]);
}
function BannedInfo() {
const data = useLoaderData<typeof loader>();
const { banned, bannedReason } = data.banned ?? {};
if (!banned) return null;
const ends = (() => {
if (!banned || banned === 1) return null;
return databaseTimestampToDate(banned);
})();
return (
<div className="mb-4">
<h2 className="text-warning">Account suspended</h2>
{bannedReason ? <div>Reason: {bannedReason}</div> : null}
{ends ? (
<div suppressHydrationWarning>
Ends:{" "}
{ends.toLocaleString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
})}
</div>
) : (
<div>
Ends: <i>no end time set</i>
</div>
)}
</div>
);
}

View File

@ -3,7 +3,7 @@ import type {
MetaFunction,
SerializeFrom,
} from "@remix-run/node";
import { json } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import {
Links,
Meta,
@ -41,10 +41,11 @@ import { useIsMounted } from "./hooks/useIsMounted";
import { DEFAULT_LANGUAGE } from "./modules/i18n/config";
import i18next, { i18nCookie } from "./modules/i18n/i18next.server";
import { browserTimingHeader } from "./utils/newrelic.server";
import { COMMON_PREVIEW_IMAGE } from "./utils/urls";
import { COMMON_PREVIEW_IMAGE, SUSPENDED_PAGE } from "./utils/urls";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { cache, ttl } from "~/utils/cache.server";
import cachified from "@epic-web/cachified";
import { userIsBanned } from "./features/ban/core/banned.server";
import "nprogress/nprogress.css";
import "~/styles/common.css";
@ -75,11 +76,18 @@ export const meta: MetaFunction = () => {
export type RootLoaderData = SerializeFrom<typeof loader>;
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await getUser(request);
const user = await getUser(request, false);
const locale = await i18next.getLocale(request);
const themeSession = await getThemeSession(request);
if (user?.banned) throw new Response(null, { status: 403 });
// avoid redirection loop
if (
user &&
userIsBanned(user?.id) &&
new URL(request.url).pathname !== SUSPENDED_PAGE
) {
return redirect(SUSPENDED_PAGE);
}
return json(
{

View File

@ -107,6 +107,7 @@ export const SENDOUQ_LOOKING_PAGE = "/q/looking";
export const SENDOUQ_LOOKING_PREVIEW_PAGE = "/q/looking?preview=true";
export const SENDOUQ_STREAMS_PAGE = "/q/streams";
export const TIERS_PAGE = "/tiers";
export const SUSPENDED_PAGE = "/suspended";
export const BLANK_IMAGE_URL = "/static-assets/img/blank.gif";
export const COMMON_PREVIEW_IMAGE =

Binary file not shown.

View File

@ -0,0 +1,5 @@
export function up(db) {
db.transaction(() => {
db.prepare(/* sql */ `alter table "User" add "bannedReason" text`).run();
})();
}

View File

@ -1,14 +0,0 @@
/* eslint-disable no-console */
import "dotenv/config";
import invariant from "tiny-invariant";
import { sql } from "~/db/sql";
const discordId = process.argv[2]?.trim();
invariant(discordId, "discord id is required (argument 1)");
sql
.prepare('update "User" set banned = 1 where discordId = @discordId')
.run({ discordId });
console.log(`Banned user with discord id: ${discordId}`);

View File

@ -1,14 +0,0 @@
/* eslint-disable no-console */
import "dotenv/config";
import invariant from "tiny-invariant";
import { sql } from "~/db/sql";
const discordId = process.argv[2]?.trim();
invariant(discordId, "discord id is required (argument 1)");
sql
.prepare('update "User" set banned = 0 where discordId = @discordId')
.run({ discordId });
console.log(`Unbanned user with discord id: ${discordId}`);

View File

@ -41,6 +41,8 @@ export default defineConfig(() => {
"features/front-page/routes/patrons-list.ts",
);
route("/suspended", "features/ban/routes/suspended.tsx");
route("/u", "features/user-search/routes/u.tsx");
route(