diff --git a/app/db/tables.ts b/app/db/tables.ts index 75788af6e..cc678c002 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -585,7 +585,9 @@ export interface UserMapModePreferences { } export interface User { + /** 1 = permabanned, timestamp = ban active till then */ banned: Generated; + bannedReason: string | null; bio: string | null; commissionsOpen: Generated; commissionText: string | null; diff --git a/app/features/admin/AdminRepository.server.ts b/app/features/admin/AdminRepository.server.ts index ef42defcc..d2f15259d 100644 --- a/app/features/admin/AdminRepository.server.ts +++ b/app/features/admin/AdminRepository.server.ts @@ -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(); +} diff --git a/app/features/admin/actions/admin.server.ts b/app/features/admin/actions/admin.server.ts new file mode 100644 index 000000000..a5c1e2d3f --- /dev/null +++ b/app/features/admin/actions/admin.server.ts @@ -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()), + }), +]); diff --git a/app/features/admin/admin-schemas.server.ts b/app/features/admin/admin-schemas.server.ts deleted file mode 100644 index ce056eca1..000000000 --- a/app/features/admin/admin-schemas.server.ts +++ /dev/null @@ -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()), - }), -]); diff --git a/app/features/admin/loaders/admin.server.ts b/app/features/admin/loaders/admin.server.ts new file mode 100644 index 000000000..d68f79f14 --- /dev/null +++ b/app/features/admin/loaders/admin.server.ts @@ -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), + }; +}; diff --git a/app/features/admin/routes/admin.test.ts b/app/features/admin/routes/admin.test.ts index a4d148bd1..50afcef3f 100644 --- a/app/features/admin/routes/admin.test.ts +++ b/app/features/admin/routes/admin.test.ts @@ -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"); diff --git a/app/features/admin/routes/admin.tsx b/app/features/admin/routes/admin.tsx index 6a9a6f07c..9da4e572e 100644 --- a/app/features/admin/routes/admin.tsx +++ b/app/features/admin/routes/admin.tsx @@ -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({ - isImpersonating: await isImpersonating(request), - }); -}; - export const handle: SendouRouteHandle = { navItemName: "admin", }; @@ -149,6 +45,8 @@ export default function AdminPage() { ) : null} {isAdmin(user) ? : null} {isAdmin(user) ? : null} + {isAdmin(user) ? : null} + {isAdmin(user) ? : null} {isAdmin(user) ? : null} {isAdmin(user) ? : null} @@ -157,7 +55,7 @@ export default function AdminPage() { function Impersonate() { const [userId, setUserId] = React.useState(); - const { isImpersonating } = useLoaderData(); + const { isImpersonating } = useLoaderData(); return (
+

Ban user

+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + Save + +
+ + ); +} + +function UnbanUser() { + const fetcher = useFetcher(); + + return ( + +

Unban user

+
+ + +
+
+ + Save + +
+
+ ); +} + function RefreshPlusTiers() { const fetcher = useFetcher(); diff --git a/app/features/auth/core/user.server.ts b/app/features/auth/core/user.server.ts index e765c90c7..e21c6f6ee 100644 --- a/app/features/auth/core/user.server.ts +++ b/app/features/auth/core/user.server.ts @@ -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 | 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; diff --git a/app/features/ban/core/banned.server.ts b/app/features/ban/core/banned.server.ts new file mode 100644 index 000000000..ef4dbfa4a --- /dev/null +++ b/app/features/ban/core/banned.server.ts @@ -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(); +} diff --git a/app/features/ban/loaders/suspended.server.ts b/app/features/ban/loaders/suspended.server.ts new file mode 100644 index 000000000..2912b3e24 --- /dev/null +++ b/app/features/ban/loaders/suspended.server.ts @@ -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> { + const session = await authSessionStorage.getSession( + request.headers.get("Cookie"), + ); + return session.get(IMPERSONATED_SESSION_KEY) ?? session.get(SESSION_KEY); +} diff --git a/app/features/ban/queries/allBannedUsers.server.ts b/app/features/ban/queries/allBannedUsers.server.ts new file mode 100644 index 000000000..0b0eb4707 --- /dev/null +++ b/app/features/ban/queries/allBannedUsers.server.ts @@ -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 & { + userId: number; +}; + +export function allBannedUsers() { + const rows = stm.all() as Array; + + const result: Map = new Map(); + + for (const row of rows) { + result.set(row.userId, row); + } + + return result; +} diff --git a/app/features/ban/routes/suspended.tsx b/app/features/ban/routes/suspended.tsx new file mode 100644 index 000000000..f4fa74b57 --- /dev/null +++ b/app/features/ban/routes/suspended.tsx @@ -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(); + + const ends = (() => { + if (!data.banned || data.banned === 1) return null; + + return databaseTimestampToDate(data.banned); + })(); + + return ( +
+

Account suspended

+ {data.reason ?
Reason: {data.reason}
: null} + {ends ? ( +
+ Ends:{" "} + {ends.toLocaleString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "numeric", + })} +
+ ) : ( +
+ Ends: no end time set +
+ )} +
+ ); +} diff --git a/app/features/sendouq/QRepository.server.ts b/app/features/sendouq/QRepository.server.ts index 87006dc51..97bd09444 100644 --- a/app/features/sendouq/QRepository.server.ts +++ b/app/features/sendouq/QRepository.server.ts @@ -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; } diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index fdd5a6b45..01f096ca4 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -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", ]) diff --git a/app/features/user-page/routes/u.$identifier.tsx b/app/features/user-page/routes/u.$identifier.tsx index dfbb12e8a..a43fc0c36 100644 --- a/app/features/user-page/routes/u.$identifier.tsx +++ b/app/features/user-page/routes/u.$identifier.tsx @@ -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() { )} - {data.banned ?
Banned
: null} + ); @@ -175,3 +180,40 @@ function useReplaceWithCustomUrl() { ); }, [location, data.customUrl]); } + +function BannedInfo() { + const data = useLoaderData(); + + const { banned, bannedReason } = data.banned ?? {}; + + if (!banned) return null; + + const ends = (() => { + if (!banned || banned === 1) return null; + + return databaseTimestampToDate(banned); + })(); + + return ( +
+

Account suspended

+ {bannedReason ?
Reason: {bannedReason}
: null} + {ends ? ( +
+ Ends:{" "} + {ends.toLocaleString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "numeric", + })} +
+ ) : ( +
+ Ends: no end time set +
+ )} +
+ ); +} diff --git a/app/root.tsx b/app/root.tsx index 4b16db683..e312635a2 100644 --- a/app/root.tsx +++ b/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; 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( { diff --git a/app/utils/urls.ts b/app/utils/urls.ts index ca60347b5..6a6fb6d8e 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -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 = diff --git a/db-test.sqlite3 b/db-test.sqlite3 index cb15a677d..176a7d9b5 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/migrations/053-banned-reason.js b/migrations/053-banned-reason.js new file mode 100644 index 000000000..bd9acf298 --- /dev/null +++ b/migrations/053-banned-reason.js @@ -0,0 +1,5 @@ +export function up(db) { + db.transaction(() => { + db.prepare(/* sql */ `alter table "User" add "bannedReason" text`).run(); + })(); +} diff --git a/scripts/ban-user.ts b/scripts/ban-user.ts deleted file mode 100644 index 278c351b3..000000000 --- a/scripts/ban-user.ts +++ /dev/null @@ -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}`); diff --git a/scripts/unban-user.ts b/scripts/unban-user.ts deleted file mode 100644 index b1bfa2ab5..000000000 --- a/scripts/unban-user.ts +++ /dev/null @@ -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}`); diff --git a/vite.config.ts b/vite.config.ts index eb4b35ebc..e367381ac 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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(