mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Timed bans feature
This commit is contained in:
parent
6d68eecaae
commit
7f25bbcd78
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
149
app/features/admin/actions/admin.server.ts
Normal file
149
app/features/admin/actions/admin.server.ts
Normal 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()),
|
||||
}),
|
||||
]);
|
||||
|
|
@ -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()),
|
||||
}),
|
||||
]);
|
||||
16
app/features/admin/loaders/admin.server.ts
Normal file
16
app/features/admin/loaders/admin.server.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
26
app/features/ban/core/banned.server.ts
Normal file
26
app/features/ban/core/banned.server.ts
Normal 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();
|
||||
}
|
||||
30
app/features/ban/loaders/suspended.server.ts
Normal file
30
app/features/ban/loaders/suspended.server.ts
Normal 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);
|
||||
}
|
||||
29
app/features/ban/queries/allBannedUsers.server.ts
Normal file
29
app/features/ban/queries/allBannedUsers.server.ts
Normal 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;
|
||||
}
|
||||
39
app/features/ban/routes/suspended.tsx
Normal file
39
app/features/ban/routes/suspended.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
16
app/root.tsx
16
app/root.tsx
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
5
migrations/053-banned-reason.js
Normal file
5
migrations/053-banned-reason.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export function up(db) {
|
||||
db.transaction(() => {
|
||||
db.prepare(/* sql */ `alter table "User" add "bannedReason" text`).run();
|
||||
})();
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
|
|
@ -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}`);
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user