diff --git a/app/features/calendar/calendar-utils.ts b/app/features/calendar/calendar-utils.ts new file mode 100644 index 000000000..b0d0ffb2c --- /dev/null +++ b/app/features/calendar/calendar-utils.ts @@ -0,0 +1,4 @@ +import { userDiscordIdIsAged } from "~/utils/users"; + +export const canAddNewEvent = (user: { discordId: string }) => + userDiscordIdIsAged(user); diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index e15c49e3d..ac49670cc 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -34,7 +34,7 @@ import type { import { useIsMounted } from "~/hooks/useIsMounted"; import { useTranslation } from "~/hooks/useTranslation"; import { useUser } from "~/features/auth/core"; -import { requireUserId } from "~/features/auth/core/user.server"; +import { requireUser } from "~/features/auth/core/user.server"; import { i18next } from "~/modules/i18n"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { canEditCalendarEvent, canEnableTOTools } from "~/permissions"; @@ -71,6 +71,7 @@ import type { RankedModeShort } from "~/modules/in-game-lists"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; import * as BadgeRepository from "~/features/badges/BadgeRepository.server"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; +import { canAddNewEvent } from "../calendar-utils"; const MIN_DATE = new Date(Date.UTC(2015, 4, 28)); @@ -136,12 +137,14 @@ const newCalendarEventActionSchema = z.object({ }); export const action: ActionFunction = async ({ request }) => { - const user = await requireUserId(request); + const user = await requireUser(request); const data = await parseRequestFormData({ request, schema: newCalendarEventActionSchema, }); + validate(canAddNewEvent(user), "Not authorized", 401); + const commonArgs = { name: data.name, description: data.description, @@ -207,9 +210,11 @@ export const handle: SendouRouteHandle = { export const loader = async ({ request }: LoaderArgs) => { const t = await i18next.getFixedT(request); - const user = await requireUserId(request); + const user = await requireUser(request); const url = new URL(request.url); + validate(canAddNewEvent(user), "Not authorized", 401); + const eventId = Number(url.searchParams.get("eventId")); const eventToEdit = Number.isNaN(eventId) ? undefined diff --git a/app/features/calendar/routes/calendar.tsx b/app/features/calendar/routes/calendar.tsx index 0ed4cf0b8..19c5ba687 100644 --- a/app/features/calendar/routes/calendar.tsx +++ b/app/features/calendar/routes/calendar.tsx @@ -44,6 +44,7 @@ import { Tags } from "../components/Tags"; import { Divider } from "~/components/Divider"; import { UsersIcon } from "~/components/icons/Users"; import * as CalendarRepository from "../CalendarRepository.server"; +import { canAddNewEvent } from "../calendar-utils"; export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: styles }]; @@ -176,7 +177,7 @@ export default function CalendarPage() {
- {user && ( + {user && canAddNewEvent(user) && ( {t("addNew")} diff --git a/app/utils/users.test.ts b/app/utils/users.test.ts index 728444a44..a4a437b89 100644 --- a/app/utils/users.test.ts +++ b/app/utils/users.test.ts @@ -1,8 +1,10 @@ import { suite } from "uvu"; import * as assert from "uvu/assert"; -import { queryToUserIdentifier } from "./users"; +import { queryToUserIdentifier, userDiscordIdIsAged } from "./users"; +import MockDate from "mockdate"; const QueryToUserIdentifier = suite("queryToUserIdentifier()"); +const UserDiscordIdIsAged = suite("userDiscordIdIsAged()"); QueryToUserIdentifier("returns null if no match", () => { assert.equal(queryToUserIdentifier("foo"), null); @@ -41,4 +43,32 @@ QueryToUserIdentifier("gets id", () => { }); }); +UserDiscordIdIsAged.before.each(() => { + MockDate.set(new Date("2023-11-25T00:00:00.000Z")); +}); + +UserDiscordIdIsAged.after.each(() => { + MockDate.reset(); +}); + +UserDiscordIdIsAged("returns false if discord id is not aged", () => { + assert.equal( + userDiscordIdIsAged({ discordId: "1177730652641181871" }), + false, + ); +}); + +UserDiscordIdIsAged("returns true if discord id is aged", () => { + assert.equal(userDiscordIdIsAged({ discordId: "79237403620945920" }), true); +}); + +UserDiscordIdIsAged("throws error if discord id missing", () => { + assert.throws(() => userDiscordIdIsAged({ discordId: "" })); +}); + +UserDiscordIdIsAged("throws error if discord id too short", () => { + assert.throws(() => userDiscordIdIsAged({ discordId: "1234" })); +}); + QueryToUserIdentifier.run(); +UserDiscordIdIsAged.run(); diff --git a/app/utils/users.ts b/app/utils/users.ts index 098c464ac..66318eb90 100644 --- a/app/utils/users.ts +++ b/app/utils/users.ts @@ -38,3 +38,30 @@ export function queryToUserIdentifier( return null; } + +// snowflake logic from https://github.dev/vegeta897/snow-stamp/blob/main/src/util.js +const DISCORD_EPOCH = 1420070400000; + +// Converts a snowflake ID string into a JS Date object using the provided epoch (in ms), or Discord's epoch if not provided +function convertSnowflakeToDate(snowflake: string) { + // Convert snowflake to BigInt to extract timestamp bits + // https://discord.com/developers/docs/reference#snowflakes + const milliseconds = BigInt(snowflake) >> 22n; + return new Date(Number(milliseconds) + DISCORD_EPOCH); +} + +const AGED_CRITERIA = 1000 * 60 * 60 * 24 * 30 * 3; // 3 months +export function userDiscordIdIsAged(user: { discordId: string }) { + // types should catch this but since this is a permission related + // code playing it safe + if (!user.discordId) { + throw new Error("No discord id"); + } + if (user.discordId.length < DISCORD_ID_MIN_LENGTH) { + throw new Error("Not a valid discord id"); + } + + const timestamp = convertSnowflakeToDate(user.discordId).getTime(); + + return Date.now() - timestamp > AGED_CRITERIA; +} diff --git a/tsconfig.json b/tsconfig.json index 95d166d1b..c1ea698b0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,7 @@ "jsx": "react-jsx", "moduleResolution": "node", "resolveJsonModule": true, - "target": "ES2019", + "target": "ES2020", "strict": true, "baseUrl": ".", "paths": {