Compare commits

...

34 Commits

Author SHA1 Message Date
26bef47403 Disable text masking and media blocking
All checks were successful
Test, Build, Deploy / Test, Build, and Deploy (push) Successful in 1m2s
Test, Build, Deploy / Create Sentry Release (push) Successful in 6s
2026-03-15 03:14:19 -04:00
47df3dc55f Combine queries into batches
All checks were successful
Test, Build, Deploy / Test, Build, and Deploy (push) Successful in 45s
Test, Build, Deploy / Create Sentry Release (push) Successful in 6s
2026-03-14 05:18:50 -04:00
994a7a7a58 Include both ray and region in report id
All checks were successful
Test, Build, Deploy / Test, Build, and Deploy (push) Successful in 47s
Test, Build, Deploy / Create Sentry Release (push) Successful in 5s
2026-03-14 02:25:53 -04:00
2ca8cc163d Add trailing slash that is apparently needed
Some checks failed
Test, Build, Deploy / Test, Build, and Deploy (push) Successful in 54s
Test, Build, Deploy / Create Sentry Release (push) Failing after 25s
2026-03-14 02:21:00 -04:00
8e34e2ce24 Send back response from sentry through tunnel
All checks were successful
Test, Build, Deploy / Test, Build, and Deploy (push) Successful in 54s
Test, Build, Deploy / Create Sentry Release (push) Successful in 7s
2026-03-14 01:47:42 -04:00
7352d0bb43 Automatically update commit_sha variable on deploy
All checks were successful
Test, Build, Deploy / Test, Build, and Deploy (push) Successful in 1m12s
Test, Build, Deploy / Create Sentry Release (push) Successful in 6s
2026-03-13 05:29:15 -04:00
291afd5eaa Set commit sha as release 2026-03-13 05:28:33 -04:00
e0eceffeb3 Ignore missing commits
All checks were successful
Test, Build, Deploy / Test, Build, and Deploy (push) Successful in 43s
Test, Build, Deploy / Create Sentry Release (push) Successful in 6s
2026-03-13 04:51:05 -04:00
187ba2f0f6 Make fetch depth zero
Some checks failed
Test, Build, Deploy / Test, Build, and Deploy (push) Successful in 1m0s
Test, Build, Deploy / Create Sentry Release (push) Failing after 4s
2026-03-13 04:49:07 -04:00
7490659467 Remove extraneous then
Some checks failed
Test, Build, Deploy / Test, Build, and Deploy (push) Successful in 48s
Test, Build, Deploy / Create Sentry Release (push) Failing after 5s
2026-03-13 04:37:06 -04:00
18947e8446 Check if installed before running script
Some checks failed
Test, Build, Deploy / Test, Build, and Deploy (push) Successful in 57s
Test, Build, Deploy / Create Sentry Release (push) Failing after 2s
2026-03-13 04:34:26 -04:00
c9b32cb581 API gets releases now
Some checks failed
Test, Build, Deploy / Test, Build, and Deploy (push) Successful in 1m4s
Test, Build, Deploy / Create Sentry Release (push) Failing after 3s
2026-03-13 04:26:05 -04:00
ddab8a9a71 Yeah that needs to be first
All checks were successful
Test, Build, Deploy / Test, Build, and Deploy (push) Successful in 56s
Test, Build, Deploy / Create Sentry Release (push) Successful in 7s
2026-03-13 04:17:23 -04:00
35612f34dd Force include dev dependencies
Some checks failed
Test, Build, Deploy / Test, Build, and Deploy (push) Failing after 2s
Test, Build, Deploy / Create Sentry Release (push) Has been skipped
2026-03-13 04:15:59 -04:00
d09360c390 Avoid using npm run for formatting check (something up with the runner?)
Some checks failed
Test, Build, Deploy / Test, Build, and Deploy (push) Failing after 30s
Test, Build, Deploy / Create Sentry Release (push) Has been skipped
2026-03-13 04:07:59 -04:00
9ab695c6a7 We finally have a workflow again
Some checks failed
Test, Build, Deploy / Test, Build, and Deploy (push) Failing after 1m28s
Test, Build, Deploy / Create Sentry Release (push) Has been skipped
2026-03-13 04:03:08 -04:00
7d29433c36 Use different projects/DSNs for remix and functions 2026-03-12 03:33:37 -04:00
d613cc1801 Fix envelope header parsing 2026-03-12 02:58:02 -04:00
f5e2110ff4 Add user tagging for logged-in staff users 2026-03-12 02:20:16 -04:00
97d84bdc17 Hide login/profile on 404s since apparently that still doesn't work 2026-03-11 04:10:16 -04:00
c2251ecfd4 More sentry monitoring 2026-03-11 04:02:33 -04:00
703510afa7 Enable sentry session replay 2026-03-11 02:28:54 -04:00
6152dd27e0 Don't reset turnstile when it's never loaded 2026-03-11 02:27:37 -04:00
09fa78e7e0 Make footers anchor to the bottom of the cards 2026-02-25 04:38:59 -05:00
ba88360636 Me when formatting problems 2026-02-25 04:33:51 -05:00
c3eda8245f Shorten landing link text 2026-02-25 04:22:18 -05:00
a9936afba2 Maybe recruiters will notice me now 2026-02-25 04:19:07 -05:00
704ffd280b New token prefixes 2026-02-25 04:15:03 -05:00
b054fb7fd5 Update some dependencies 2026-02-25 04:14:42 -05:00
7c7448137a Finally fix uncentered team cards 2026-02-25 00:01:33 -05:00
f2f01f42c2 Update team list 2026-02-24 23:39:19 -05:00
e5a00d69a6 Account for reset names that are outside of usual bounds 2026-02-16 18:31:53 -05:00
25c9049563 Allow new Roblox reset username format 2026-02-15 00:36:24 -05:00
81b845c9d1 Update dependencies 2026-02-15 00:19:17 -05:00
33 changed files with 2730 additions and 2323 deletions

View File

@@ -0,0 +1,94 @@
name: Test, Build, Deploy
on: [push]
jobs:
Test-Build-Deploy:
name: Test, Build, and Deploy
runs-on: ubuntu-latest
env:
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_PROJECT_NAME: ${{ vars.CLOUDFLARE_PROJECT_NAME }}
COMMIT_SHA: ${{ gitea.sha }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: Install Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f
with:
node-version-file: .node-version
- name: Install Wrangler
run: npm install -g wrangler
- name: Install Dependencies
run: npm ci --include=dev
- name: Check Formatting
run: npm run check-format
- name: Build
run: npm run build
- name: Set Version as Var
run: |
curl https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/pages/projects/$CLOUDFLARE_PROJECT_NAME \
-X PATCH \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"deployment_configs": {
"production": {
"env_vars": {
"COMMIT_SHA": {
"type": "plain_text",
"value": "'"$COMMIT_SHA"'"
}
}
}
}
}'
- name: Deploy
run: wrangler pages deploy public --project-name $CLOUDFLARE_PROJECT_NAME
Sentry-Release:
name: Create Sentry Release
runs-on: ubuntu-latest
needs: Test-Build-Deploy
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: Install Sentry SDK
run: |
if ! [ -x "$(command -v sentry-cli)" ]; then
curl -sL https://sentry.io/get-cli/ | bash
fi
- name: Create Remix Release
run: |
VERSION=$(sentry-cli releases propose-version)
sentry-cli releases new $VERSION
sentry-cli releases set-commits --auto $VERSION --ignore-missing
sentry-cli releases finalize $VERSION
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_REMIX_PROJECT }}
- name: Create API Release
run: |
VERSION=$(sentry-cli releases propose-version)
sentry-cli releases new $VERSION
sentry-cli releases set-commits --auto $VERSION --ignore-missing
sentry-cli releases finalize $VERSION
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_API_PROJECT }}

View File

@@ -1 +1 @@
v22.20.0 v24.14.0

View File

@@ -2,16 +2,35 @@ import { CacheProvider } from "@emotion/react";
import { ClientStyleContext } from "./context.js"; import { ClientStyleContext } from "./context.js";
import createEmotionCache from "./createEmotionCache.js"; import createEmotionCache from "./createEmotionCache.js";
import { hydrateRoot } from "react-dom/client"; import { hydrateRoot } from "react-dom/client";
import { RemixBrowser } from "@remix-run/react"; import { RemixBrowser, useLocation, useMatches } from "@remix-run/react";
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/remix";
import { type ReactNode, StrictMode, useState } from "react"; import { type ReactNode, StrictMode, useEffect, useState } from "react";
Sentry.init({ Sentry.init({
dsn: dsn:
document.querySelector("meta[name='dsn']")?.getAttribute("content") ?? document.querySelector("meta[name='dsn']")?.getAttribute("content") ??
undefined, undefined,
integrations: [Sentry.browserTracingIntegration()], integrations: [
Sentry.browserTracingIntegration({
useEffect,
useLocation,
useMatches,
}),
Sentry.replayIntegration({
blockAllMedia: false,
maskAllInputs: false,
maskAllText: false,
}),
],
replaysOnErrorSampleRate: 1,
replaysSessionSampleRate: 0.02,
release:
document
.querySelector("meta[name='commit_sha']")
?.getAttribute("content") ?? undefined,
sendDefaultPii: true,
tracesSampleRate: 0.1, tracesSampleRate: 0.1,
tunnel: "/api/st",
}); });
function ClientCacheProvider({ children }: { children: ReactNode }) { function ClientCacheProvider({ children }: { children: ReactNode }) {

View File

@@ -5,6 +5,7 @@ import { type EntryContext } from "@remix-run/cloudflare";
import { RemixServer } from "@remix-run/react"; import { RemixServer } from "@remix-run/react";
import { renderToString } from "react-dom/server"; import { renderToString } from "react-dom/server";
import { ServerStyleContext } from "./context.js"; import { ServerStyleContext } from "./context.js";
import * as Sentry from "@sentry/remix";
export default function handleRequest( export default function handleRequest(
request: Request, request: Request,
@@ -39,3 +40,5 @@ export default function handleRequest(
status: responseStatusCode, status: responseStatusCode,
}); });
} }
export const handleError = Sentry.sentryHandleError;

View File

@@ -18,6 +18,7 @@ import {
Scripts, Scripts,
useLoaderData, useLoaderData,
useRouteError, useRouteError,
useRouteLoaderData,
} from "@remix-run/react"; } from "@remix-run/react";
import { type ErrorResponse } from "@remix-run/router"; import { type ErrorResponse } from "@remix-run/router";
import { LinksFunction } from "@remix-run/cloudflare"; import { LinksFunction } from "@remix-run/cloudflare";
@@ -26,71 +27,89 @@ import Navigation from "../components/Navigation.js";
import { type ReactNode, StrictMode, useContext, useEffect } from "react"; import { type ReactNode, StrictMode, useContext, useEffect } from "react";
import theme from "../theme.js"; import theme from "../theme.js";
import { withEmotionCache } from "@emotion/react"; import { withEmotionCache } from "@emotion/react";
import {
captureRemixErrorBoundaryError,
setUser,
withSentry,
} from "@sentry/remix";
export function ErrorBoundary() { export function ErrorBoundary() {
const error = useRouteError() as ErrorResponse; const error = useRouteError() as ErrorResponse;
if (!isRouteErrorResponse(error)) if (!isRouteErrorResponse(error))
return getMarkup( return (
{ hide: true }, <DocumentWrapper loaderData={{ hide: true }}>
<Container maxW="container.lg" pt="8vh" textAlign="left"> <Container maxW="container.lg" pt="8vh" textAlign="left">
<Heading size="4xl">???</Heading> <Heading size="4xl">???</Heading>
<br /> <br />
<Text fontSize="xl">Something bad happened!</Text> <Text fontSize="xl">Something bad happened!</Text>
<br /> <br />
<br /> <br />
<br /> <br />
<Text>Details: {error}</Text> <Text>Details: {error}</Text>
<br /> <br />
<br /> <br />
<Link color="#646cff" onClick={() => location.reload()}> <Link color="#646cff" onClick={() => location.reload()}>
Refresh Refresh
</Link> </Link>
</Container>, </Container>
</DocumentWrapper>
); );
const { status } = error; const { status } = error;
const loaderData = useRouteLoaderData<typeof loader>("root") || {};
switch (status) { switch (status) {
case 303: case 303:
return ""; return "";
case 401: case 401:
return getMarkup({ hide: true }, <Login />); return (
<DocumentWrapper loaderData={loaderData}>
<Login />
</DocumentWrapper>
);
case 403: case 403:
return getMarkup({ hide: true }, <Forbidden />); return (
<DocumentWrapper loaderData={loaderData}>
<Forbidden />
</DocumentWrapper>
);
case 404: case 404:
return getMarkup( return (
{ hide: true }, <DocumentWrapper loaderData={{ ...loaderData, hide: true }}>
<Container maxW="container.lg" pt="8vh" textAlign="left"> <Container maxW="container.lg" pt="8vh" textAlign="left">
<Heading size="4xl">404</Heading> <Heading size="4xl">404</Heading>
<br /> <br />
<Text fontSize="xl">There is nothing to find here.</Text> <Text fontSize="xl">There is nothing to find here.</Text>
<br /> <br />
<br /> <br />
<br /> <br />
<Link color="#646cff" onClick={() => history.go(-1)}> <Link color="#646cff" onClick={() => history.go(-1)}>
Go back Go back
</Link> </Link>
</Container>, </Container>
</DocumentWrapper>
); );
default: default:
return getMarkup( captureRemixErrorBoundaryError(useRouteError());
{ hide: true }, return (
<Container maxW="container.lg" pt="8vh" textAlign="left"> <DocumentWrapper loaderData={loaderData}>
<Heading size="4xl">500</Heading> <Container maxW="container.lg" pt="8vh" textAlign="left">
<br /> <Heading size="4xl">500</Heading>
<Text fontSize="xl">S̶̡͈̠̗̠͖͙̭o̶̶͕͚̥͍̪̤m̸̨͏͈͔̖͚̖̰̱͞e҉̵͖͚͇̀t̕͟͠͏͎̺̯̲̱̣̤̠̟͙̠̙̫̬ḩ̸̭͓̬͎̙̀į̞̮͉͖̰̥̹͚̫̙̪̗̜̳̕ͅn҉͔̯̪̗̝̝͖̲͇͍͎̲̲̤̖̫͈̪͡g̴̰̻̙̝͉̭͇̖̰̝̙͕̼͙͘͜ ̵̶̫̥̳̲̘̻̗͈͕̭̲͇̘̜̺̟̥̖̥b̴̙̭̹͕̞͠r̞͎̠̩͈̖̰̞̯̯͢͢͠ͅo̝̯̗̹̳͍̰͉͕̘̰̠̺̥̰͔̕ͅk̵̸̻̠͕̺̦̦͖̲̺̦̞̝̞͞͡e̶͏̤̼̼͔̘̰̰̭͈̀͞͡</Text> <br />
<br /> <Text fontSize="xl">S̶̡͈̠̗̠͖͙̭o̶̶͕͚̥͍̪̤m̸̨͏͈͔̖͚̖̰̱͞e҉̵͖͚͇̀t̕͟͠͏͎̺̯̲̱̣̤̠̟͙̠̙̫̬ḩ̸̭͓̬͎̙̀į̞̮͉͖̰̥̹͚̫̙̪̗̜̳̕ͅn҉͔̯̪̗̝̝͖̲͇͍͎̲̲̤̖̫͈̪͡g̴̰̻̙̝͉̭͇̖̰̝̙͕̼͙͘͜ ̵̶̫̥̳̲̘̻̗͈͕̭̲͇̘̜̺̟̥̖̥b̴̙̭̹͕̞͠r̞͎̠̩͈̖̰̞̯̯͢͢͠ͅo̝̯̗̹̳͍̰͉͕̘̰̠̺̥̰͔̕ͅk̵̸̻̠͕̺̦̦͖̲̺̦̞̝̞͞͡e̶͏̤̼̼͔̘̰̰̭͈̀͞͡</Text>
<br /> <br />
<br /> <br />
<Link color="#646cff" onClick={() => location.reload()}> <br />
Reload <Link color="#646cff" onClick={() => location.reload()}>
</Link> Reload
</Container>, </Link>
</Container>
</DocumentWrapper>
); );
} }
} }
@@ -111,8 +130,9 @@ export async function loader({
}): Promise<{ [k: string]: any }> { }): Promise<{ [k: string]: any }> {
let data: { [k: string]: string } = {}; let data: { [k: string]: string } = {};
if (context.env.COMMIT_SHA) data.commit_sha = context.env.COMMIT_SHA;
if (context.data.current_user) data = { ...context.data.current_user }; if (context.data.current_user) data = { ...context.data.current_user };
if (context.env.DSN) data.dsn = context.env.DSN; if (context.env.REMIX_DSN) data.dsn = context.env.REMIX_DSN;
if (context.data.nonce) data.nonce = context.data.nonce; if (context.data.nonce) data.nonce = context.data.nonce;
if (context.data.theme) data.theme = context.data.theme; if (context.data.theme) data.theme = context.data.theme;
@@ -123,10 +143,11 @@ export function meta() {
return [{ title: "Car Crushers" }]; return [{ title: "Car Crushers" }];
} }
function getMarkup( function DocumentWrapper(props: {
loaderData: { [k: string]: any }, loaderData: { [k: string]: any };
child: ReactNode, children: ReactNode;
): JSX.Element { }) {
const { children: child, loaderData } = props;
const Document = withEmotionCache( const Document = withEmotionCache(
({ children }: { children: ReactNode }, emotionCache) => { ({ children }: { children: ReactNode }, emotionCache) => {
const serverStyleData = useContext(ServerStyleContext); const serverStyleData = useContext(ServerStyleContext);
@@ -182,6 +203,9 @@ function getMarkup(
/> />
))} ))}
<meta charSet="UTF-8" /> <meta charSet="UTF-8" />
{loaderData.commit_sha ? (
<meta name="commit_sha" content={loaderData.commit_sha} />
) : null}
{loaderData.dsn ? ( {loaderData.dsn ? (
<meta name="dsn" content={loaderData.dsn} /> <meta name="dsn" content={loaderData.dsn} />
) : null} ) : null}
@@ -207,8 +231,26 @@ function getMarkup(
return <Document>{child}</Document>; return <Document>{child}</Document>;
} }
export default function () { function App() {
const loaderData = useLoaderData<typeof loader>(); const loaderData = useLoaderData<typeof loader>();
return getMarkup(loaderData, <Outlet />); if (
loaderData.id &&
[0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].find(
(p) => loaderData.permissions & (1 << p),
)
)
setUser({
email: loaderData.email,
id: loaderData.id,
username: loaderData.username,
});
return (
<DocumentWrapper loaderData={loaderData}>
<Outlet />
</DocumentWrapper>
);
} }
export default withSentry(App);

View File

@@ -7,4 +7,4 @@ export default function () {
message="Your request is now being processed; this normally takes 1-2 weeks." message="Your request is now being processed; this normally takes 1-2 weeks."
/> />
); );
} }

View File

@@ -31,4 +31,4 @@ export default function () {
</Card> </Card>
</Container> </Container>
); );
} }

View File

@@ -35,29 +35,24 @@ export async function loader({ context }: { context: RequestContext }) {
month = 12; month = 12;
} }
const eventMemberQuery = await context.env.D1.prepare( const batchStatements: D1Result<Record<string, any>>[] =
"SELECT id, name, roblox_id FROM et_members;", await context.env.D1.batch([
).all(); context.env.D1.prepare("SELECT id, name, roblox_id FROM et_members;"),
const inactivityQuery: D1Result<Record<string, any>> = context.env.D1.prepare(
await context.env.D1.prepare( "SELECT decisions, json_extract(user, '$.id') AS uid FROM inactivity_notices WHERE (end BETWEEN DATE('now', 'start of month', '-1 month') AND DATE('now', 'start of month', '-1 day')) OR (start BETWEEN DATE('now', 'start of month', '-1 month') AND DATE('now', 'start of month', '-1 day'));",
"SELECT decisions, json_extract(user, '$.id') AS uid FROM inactivity_notices WHERE (end BETWEEN DATE('now', 'start of month', '-1 month') AND DATE('now', 'start of month', '-1 day')) OR (start BETWEEN DATE('now', 'start of month', '-1 month') AND DATE('now', 'start of month', '-1 day'));", ),
).all(); context.env.D1.prepare(
"SELECT approved, answered_at, created_by, performed_at, reached_minimum_player_count, type FROM events WHERE month = ? AND year = ?;",
).bind(month, year),
]);
const eventsQuery = await context.env.D1.prepare(
"SELECT approved, answered_at, created_by, performed_at, reached_minimum_player_count, type FROM events WHERE month = ? AND year = ?;",
)
.bind(month, year)
.all();
const memberMap = Object.fromEntries( const memberMap = Object.fromEntries(
eventMemberQuery.results.map((entry) => { batchStatements[0].results.map((e) => {
return [ return [e.id, { name: e.name, points: 0, roblox_id: e.roblox_id }];
entry.id,
{ name: entry.name, points: 0, roblox_id: entry.roblox_id },
];
}), }),
); );
for (const event of eventsQuery.results as { for (const event of batchStatements[2].results as {
approved: number; approved: number;
answered_at: number; answered_at: number;
created_by: string; created_by: string;
@@ -77,10 +72,10 @@ export async function loader({ context }: { context: RequestContext }) {
for (const member of Object.keys(memberMap)) for (const member of Object.keys(memberMap))
if ( if (
(memberMap[member].points < 50 || (memberMap[member].points < 50 ||
eventsQuery.results.filter( batchStatements[2].results.filter(
(e) => e.type === "gamenight" && e.created_by === member, (e) => e.type === "gamenight" && e.created_by === member,
).length === 0) && ).length === 0) &&
!inactivityQuery.results.find( !batchStatements[1].results.find(
(i) => i.uid === member && JSON.parse(i.decisions).et, (i) => i.uid === member && JSON.parse(i.decisions).et,
) )
) )

View File

@@ -33,37 +33,31 @@ export async function loader({ context }: { context: RequestContext }) {
if (!currentUser) throw new Response(null, { status: 401 }); if (!currentUser) throw new Response(null, { status: 401 });
const d1Promises = []; const batchStatements = [];
for (const itemType of ["appeals", "inactivity_notices", "reports"]) for (const itemType of ["appeals", "inactivity_notices", "reports"]) {
d1Promises.push( batchStatements.push(
context.env.D1.prepare( context.env.D1.prepare(
`SELECT * `SELECT * FROM ${itemType} WHERE json_extract(user, '$.id') = ? ORDER BY created_at DESC;`,
FROM ${itemType} ).bind(currentUser.id),
WHERE json_extract(user, '$.id') = ?
ORDER BY created_at DESC;`,
)
.bind(currentUser.id)
.all(),
); );
const settledPromises = await Promise.allSettled(d1Promises);
let etData: { [k: string]: any } | null = null;
if (currentUser.permissions & (1 << 3)) {
etData = await context.env.D1.prepare(
"SELECT name, points, roblox_id FROM et_members WHERE id = ?;",
)
.bind(currentUser.id)
.first();
} }
return { if (currentUser.permissions & (1 << 3))
etData, batchStatements.push(
items: settledPromises.map((p) => { context.env.D1.prepare(
if (p.status === "fulfilled") return p.value.results; "SELECT name, points, roblox_id FROM et_members WHERE id = ? LIMIT 1;",
).bind(currentUser.id),
);
return null; const batchResults = await context.env.D1.batch(batchStatements);
return {
etData: batchResults.at(3)?.results?.at(0) as { [k: string]: any } | null,
items: batchResults.map((r) => {
if (r.success) return r.results;
return [];
}) as any as ({ [k: string]: any }[] | null)[], }) as any as ({ [k: string]: any }[] | null)[],
permissions: currentUser.permissions as number, permissions: currentUser.permissions as number,
}; };

View File

@@ -116,7 +116,8 @@ export default function () {
data storage provider. data storage provider.
</li> </li>
<li> <li>
<strong>Google, LLC. (Mountain View, CA)</strong>: Push notifications (if you use the app). <strong>Google, LLC. (Mountain View, CA)</strong>: Push notifications
(if you use the app).
</li> </li>
<li> <li>
<strong>Mailgun Technologies, Inc. (San Antonio, TX)</strong>: Email <strong>Mailgun Technologies, Inc. (San Antonio, TX)</strong>: Email

78
app/routes/proj-disp.tsx Normal file
View File

@@ -0,0 +1,78 @@
import {
Card,
CardBody,
CardFooter,
CardHeader,
Container,
Divider,
Heading,
Image,
Link,
Text,
} from "@chakra-ui/react";
import projects from "../../data/public-projects.json";
export function meta() {
return [
{
title: "For Hiring Teams - Car Crushers",
},
{
name: "robots",
content: "noindex",
},
];
}
export default function () {
return (
<Container maxW="container.xl">
<Heading textAlign="start">About Evan</Heading>
<br />
<Text textAlign="start">
Hello! Thank you for your interest in me as a candidate. Are you
wondering if I did all those things I mentioned on my resume? If so,
yes, I did. Below is a list of some things I have worked on during my
time here, some of them more strongly related than others. Thank you for
taking the time to look at a history of my past and current work.
</Text>
<br />
<div
style={{
display: "grid",
gap: "1rem",
gridTemplateColumns: "repeat(auto-fill, minmax(16rem, 1fr))",
justifyItems: "center",
}}
>
{projects.map((project) => (
<Card borderRadius="36px" key={project.name} p="12px" w="100%">
<Image
alt={`Logo for ${project.name}`}
borderRadius="36px"
src={`/files/${project.image}`}
/>
<CardHeader>
<b>
<Heading size="md">{project.name}</Heading>
</b>
<Text>Time Span: {project.timespan}</Text>
</CardHeader>
<CardBody>
<Text>{project.description}</Text>
</CardBody>
<Divider />
<CardFooter gap="16px" justifyContent="center">
{project.work_link ? (
<Link href={project.work_link}>Project Work</Link>
) : null}
{project.landing_link ? (
<Link href={project.landing_link}>Project Site</Link>
) : null}
</CardFooter>
</Card>
))}
</div>
</Container>
);
}

View File

@@ -229,8 +229,10 @@ export default function () {
method: "POST", method: "POST",
}); });
// @ts-expect-error if (!logged_in) {
turnstile.reset(); // @ts-expect-error
turnstile.reset();
}
return toast({ return toast({
description: "Failed to upload file", description: "Failed to upload file",

View File

@@ -34,11 +34,11 @@ export default function () {
display: "grid", display: "grid",
gap: "1rem", gap: "1rem",
gridTemplateColumns: "repeat(auto-fill, minmax(16rem, 1fr))", gridTemplateColumns: "repeat(auto-fill, minmax(16rem, 1fr))",
width: "100%", justifyItems: "center",
}} }}
> >
{team.map((member) => ( {team.map((member) => (
<Card borderRadius="36px" key={member.id} maxW="xs" p="12px"> <Card borderRadius="36px" key={member.id} p="12px" width="100%">
<Image <Image
alt={member.tag + "'s avatar"} alt={member.tag + "'s avatar"}
borderRadius="50%" borderRadius="50%"

40
data/public-projects.json Normal file
View File

@@ -0,0 +1,40 @@
[
{
"name": "RoVer",
"image": "public-projects-icons/rover.webp",
"work_link": "https://github.com/evaera/RoVer/commits?author=Regalijan",
"landing_link": "https://rover.link/",
"timespan": "2020 - 2021",
"description": "I contributed to RoVer, a Discord bot that links Roblox accounts to manage server access. Working on the very first version gave me hands-on experience shipping production software and collaborating in an open-source project. The current iteration supports 370k+ servers and 15M+ users, underscoring the importance of maintainable, scalable code."
},
{
"name": "Car Crushers Web (This Website)",
"image": "logo512.png",
"landing_link": "/about",
"timespan": "2021 - Present",
"description": "A full-stack web platform I designed and built end-to-end, evolving from a basic HTML/Bootstrap prototype into a modern Remix v2 + React application powered by Cloudflare Workers. It supports internal and community tools for scheduling events, handling moderation workflows (reports, bans, and appeals), and managing time-off requests, with access control driven by Discord roles following the principle of least privilege."
},
{
"name": "Noblox.js",
"image": "public-projects-icons/noblox.png",
"work_link": "https://github.com/noblox/noblox.js",
"landing_link": "https://noblox.js.org/",
"timespan": "2023 - 2025",
"description": "Noblox is an open-source JavaScript library designed to simplify interactions with the Roblox API. My work on this project focused on improving functionality, code reliability, and documentation for the developer community. In 2024, I became a maintainer, taking on responsibilities such as reviewing pull requests, managing contributions, and coordinating with other developers to ensure code quality and consistency. Through this experience, I strengthened my skills in asynchronous JavaScript, API design, and open-source collaboration workflows. While active development has slowed as Robloxs official APIs and documentation have matured, the project remains a valuable tool for developers and a meaningful example of community-driven software maintenance."
},
{
"name": "Car Crushers Forum",
"image": "public-projects-icons/cc-forums.png",
"landing_link": "https://ccdiscussion.com/",
"timespan": "2019 - Present",
"description": "I set up and operated a Discourse forum on GCP using Debian, handling both application-level configuration and cloud infrastructure. Managed deployments, upgrades, and performance tuning while ensuring a secure, stable, and scalable environment for active users."
},
{
"name": "Various Discourse Forum Plugins",
"image": "public-projects-icons/discourse.png",
"work_link": "https://github.com/Regalijan?tab=repositories&language=ruby",
"landing_link": "https://meta.discourse.org/search?expanded=true&q=%40Wolftallemo%20%23plugin%20in%3Afirst",
"timespan": "2021 - Present",
"description": "While operating the Car Crushers Forum, I authored and maintained multiple Discourse plugins, and contributed patches upstream. Publicly available on Discourse Meta, these plugins improved user experience, made life better for forum administrators, and showcased a strong commitment to community-driven software."
}
]

View File

@@ -14,11 +14,6 @@
"tag": "SkilledOn", "tag": "SkilledOn",
"position": "Community Manager/Developer" "position": "Community Manager/Developer"
}, },
{
"id": "272138724488249345",
"tag": "Exoniphy",
"position": "Developer"
},
{ {
"id": "396347223736057866", "id": "396347223736057866",
"tag": "Regalijan", "tag": "Regalijan",
@@ -34,11 +29,6 @@
"tag": "Dan", "tag": "Dan",
"position": "Head of Discord Moderation" "position": "Head of Discord Moderation"
}, },
{
"id": "385572238331478016",
"tag": "Nahu",
"position": "Head of Discord Moderation"
},
{ {
"id": "151098768807165952", "id": "151098768807165952",
"tag": "Redious", "tag": "Redious",

View File

@@ -15,6 +15,7 @@
"DEVIL-DOGS", "DEVIL-DOGS",
"DOUBT", "DOUBT",
"DREADNOUGHT", "DREADNOUGHT",
"DRUM-SHOW",
"DUNDEE", "DUNDEE",
"DYING-IN-LA", "DYING-IN-LA",
"EMERGENCY-CONTACT", "EMERGENCY-CONTACT",
@@ -23,6 +24,7 @@
"FLAWLESS-EXECUTION", "FLAWLESS-EXECUTION",
"FLU-GAME", "FLU-GAME",
"FORMIDABLE", "FORMIDABLE",
"FROZEN-PISS-2",
"GATES-OF-GLORY", "GATES-OF-GLORY",
"GIRLS-GIRLS-BOYS", "GIRLS-GIRLS-BOYS",
"GONER", "GONER",
@@ -47,8 +49,11 @@
"NOT-TODAY", "NOT-TODAY",
"NO-CHANCES", "NO-CHANCES",
"OVERCOMPENSATE", "OVERCOMPENSATE",
"P.A.R.T.Y.",
"POLARIZE", "POLARIZE",
"PSYCHO", "PSYCHO",
"RAINBOW-VEINS",
"RAWFEAR",
"ROMANCE", "ROMANCE",
"SAD-CLOWN", "SAD-CLOWN",
"SATURDAY", "SATURDAY",

View File

@@ -1,5 +1,6 @@
import getPermissions from "./permissions.js"; import getPermissions from "./permissions.js";
import { jsonError } from "./common.js"; import { jsonError } from "./common.js";
import * as Sentry from "@sentry/cloudflare";
async function constructHTML(context: RequestContext) { async function constructHTML(context: RequestContext) {
const { pathname } = new URL(context.request.url); const { pathname } = new URL(context.request.url);
@@ -30,8 +31,21 @@ async function generateTokenHash(token: string) {
async function refreshAuth(context: RequestContext) { async function refreshAuth(context: RequestContext) {
const { current_user: currentUser } = context.data; const { current_user: currentUser } = context.data;
if (!currentUser || currentUser.refresh_at > Date.now()) if (!currentUser) return await context.next();
return await context.next();
if (currentUser.refresh_at > Date.now()) {
if (
[0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].find(
(p) => currentUser.permissions & (1 << p),
)
)
Sentry.setUser({
email: currentUser.email,
id: currentUser.id,
ip_address: context.request.headers.get("cf-connecting-ip"),
username: currentUser.username,
});
}
const oauthData = await context.env.DATA.get( const oauthData = await context.env.DATA.get(
`oauthcredentials_${currentUser.id}`, `oauthcredentials_${currentUser.id}`,
@@ -94,6 +108,18 @@ async function refreshAuth(context: RequestContext) {
: undefined, : undefined,
); );
if (
[0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].find(
(p) => currentUser.permissions & (1 << p),
)
)
Sentry.setUser({
email: currentUser.email,
id: currentUser.id,
ip_address: context.request.headers.get("cf-connecting-ip"),
username: currentUser.username,
});
const tokenHash = await generateTokenHash(context.data.sid); const tokenHash = await generateTokenHash(context.data.sid);
await context.env.DATA.put(`auth_${tokenHash}`, JSON.stringify(userData), { await context.env.DATA.put(`auth_${tokenHash}`, JSON.stringify(userData), {
@@ -272,7 +298,8 @@ async function setAuth(context: RequestContext) {
async function setBody(context: RequestContext) { async function setBody(context: RequestContext) {
if ( if (
["PATCH", "POST", "PUT"].includes(context.request.method) && ["PATCH", "POST", "PUT"].includes(context.request.method) &&
!context.request.url.endsWith("/api/infractions/new") !context.request.url.endsWith("/api/infractions/new") &&
!context.request.url.endsWith("/api/st")
) { ) {
if ( if (
!context.request.headers !context.request.headers
@@ -354,6 +381,11 @@ async function setTheme(context: RequestContext) {
} }
export const onRequest = [ export const onRequest = [
Sentry.sentryPagesPlugin((context: RequestContext) => ({
dsn: context.env.FUNCTIONS_DSN,
release: context.env.COMMIT_SHA,
sendDefaultPii: true,
})),
setAuth, setAuth,
refreshAuth, refreshAuth,
setTheme, setTheme,

View File

@@ -19,5 +19,7 @@ export async function onRequestPost(context: RequestContext) {
await context.env.D1.prepare( await context.env.D1.prepare(
"INSERT INTO game_mod_notes (content, created_at, created_by, id, target) VALUES (?, ?, ?, ?, ?);", "INSERT INTO game_mod_notes (content, created_at, created_by, id, target) VALUES (?, ?, ?, ?, ?);",
).bind(content, Date.now(), context.data.current_user.id, id, target).first(); )
.bind(content, Date.now(), context.data.current_user.id, id, target)
.first();
} }

View File

@@ -75,13 +75,22 @@ export async function onRequestPost(context: RequestContext) {
); );
for (const username of usernames) { for (const username of usernames) {
// Roblox thought this name was inappropriate, the standard username rules may not apply.
const isResetUsername = username.match(/roblox_user_\d+/);
if ( if (
username.length < 3 || !isResetUsername &&
username.length > 20 || (username.length < 3 ||
username.match(/_/g)?.length > 1 || username.length > 20 ||
username.match(/\W/) username.match(/_/g)?.length > 1 ||
username.match(/\W/))
) )
return jsonError(`Username "${username}" is invalid`, 400); return jsonError(`Username "${username}" is invalid`, 400);
else if (isResetUsername && username.length > 30)
return jsonError(
`Username "${username}" is not a valid reset username`,
400,
);
} }
const rbxSearchReq = await fetch( const rbxSearchReq = await fetch(
@@ -185,8 +194,7 @@ export async function onRequestPost(context: RequestContext) {
const reportId = `${Date.now()}${context.request.headers const reportId = `${Date.now()}${context.request.headers
.get("cf-ray") .get("cf-ray")
?.split("-") ?.replace("-", "")}${crypto.randomUUID().replaceAll("-", "")}`;
?.at(0)}${crypto.randomUUID().replaceAll("-", "")}`;
const { current_user: currentUser } = context.data; const { current_user: currentUser } = context.data;
if (filesToProcess.length) if (filesToProcess.length)

35
functions/api/st.ts Normal file
View File

@@ -0,0 +1,35 @@
import { jsonError } from "../common.js";
export async function onRequestPost(context: RequestContext) {
const clonedRequest = context.request.clone();
const header = (await context.request.text()).split("\n").at(0);
if (!header) return jsonError("Failed to parse envelope header", 500);
const { dsn } = JSON.parse(header);
if (context.env.REMIX_DSN !== dsn) return jsonError("Bad or no DSN", 400);
const sentryUrl = new URL(dsn);
const resp = await fetch(
`https://${sentryUrl.host}/api${sentryUrl.pathname}/envelope/`,
{
body: clonedRequest.body,
headers: {
"content-type": "application/x-sentry-envelope",
"x-forwarded-for": context.request.headers.get(
"cf-connecting-ip",
) as string,
},
method: "POST",
},
);
return new Response(await resp.text(), {
headers: {
"content-type": resp.headers.get("content-type") || "text/plain",
},
status: resp.status,
});
}

4431
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "remix build --sourcemap", "build": "remix build --sourcemap",
"check-format": "prettier -c .",
"format": "prettier -wc .", "format": "prettier -wc .",
"publish": "remix build --sourcemap && wrangler pages deploy public" "publish": "remix build --sourcemap && wrangler pages deploy public"
}, },
@@ -13,29 +14,30 @@
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@fontsource-variable/plus-jakarta-sans": "^5.2.8", "@fontsource-variable/plus-jakarta-sans": "^5.2.8",
"@remix-run/cloudflare": "^2.17.1", "@remix-run/cloudflare": "^2.17.4",
"@remix-run/cloudflare-pages": "^2.17.1", "@remix-run/cloudflare-pages": "^2.17.4",
"@remix-run/react": "^2.17.1", "@remix-run/react": "^2.17.4",
"@sentry/react": "^10.21.0", "@sentry/cloudflare": "^10.43.0",
"@sentry/remix": "^10.43.0",
"aws4fetch": "^1.0.20", "aws4fetch": "^1.0.20",
"dayjs": "^1.11.18", "dayjs": "^1.11.19",
"framer-motion": "^12.23.24", "framer-motion": "^12.35.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-big-calendar": "^1.19.4", "react-big-calendar": "^1.19.4",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@remix-run/dev": "^2.17.1", "@remix-run/dev": "^2.17.4",
"@types/node": "^24.9.1", "@types/node": "^24.12.0",
"@types/react": "^18.3.26", "@types/react": "^18.3.28",
"@types/react-big-calendar": "^1.16.3", "@types/react-big-calendar": "^1.16.3",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"dotenv": "^17.2.3", "dotenv": "^17.3.1",
"prettier": "^3.6.2", "prettier": "^3.8.1",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
"overrides": { "overrides": {
"@cloudflare/workers-types": "^4.20251014.0" "@cloudflare/workers-types": "^4.20260310.1"
}, },
"prettier": { "prettier": {
"endOfLine": "auto" "endOfLine": "auto"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB