From 42cbbb72b6028bfa30f39ffe8aa3fbbdf6c8c314 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Thu, 22 Feb 2024 08:56:25 -0700 Subject: [PATCH] upgrade to vite (#624) --- .gitignore | 2 +- app/entry.server.tsx | 2 +- app/root.tsx | 4 +- app/routes/_auth+/auth.$provider.callback.ts | 9 +- app/routes/_auth+/forgot-password.tsx | 2 +- app/routes/_auth+/login.server.ts | 158 ++++++++++++++ app/routes/_auth+/login.tsx | 170 +-------------- app/routes/_auth+/onboarding.server.ts | 19 ++ app/routes/_auth+/onboarding.tsx | 19 +- .../_auth+/onboarding_.$provider.server.ts | 19 ++ app/routes/_auth+/onboarding_.$provider.tsx | 28 +-- app/routes/_auth+/reset-password.server.ts | 34 +++ app/routes/_auth+/reset-password.tsx | 39 +--- app/routes/_auth+/signup.tsx | 2 +- app/routes/_auth+/verify.server.ts | 205 +++++++++++++++++ app/routes/_auth+/verify.tsx | 206 +----------------- app/routes/_seo+/sitemap[.]xml.ts | 8 +- app/routes/admin+/cache_.lru.$cacheKey.ts | 7 +- app/routes/admin+/cache_.sqlite.$cacheKey.ts | 7 +- app/routes/admin+/cache_.sqlite.server.ts | 29 +++ app/routes/admin+/cache_.sqlite.tsx | 29 +-- .../settings+/profile.change-email.server.tsx | 124 +++++++++++ app/routes/settings+/profile.change-email.tsx | 131 +---------- .../settings+/profile.two-factor.disable.tsx | 2 +- .../settings+/profile.two-factor.verify.tsx | 2 +- .../$username_+/__note-editor.server.tsx | 131 +++++++++++ .../users+/$username_+/__note-editor.tsx | 143 +----------- .../$username_+/notes.$noteId_.edit.tsx | 4 +- app/routes/users+/$username_+/notes.new.tsx | 5 +- app/utils/cache.server.ts | 2 +- app/utils/litefs.server.ts | 9 +- docs/decisions/036-vite.md | 32 +++ package-lock.json | 153 ++++++------- package.json | 25 +-- remix.config.js | 24 -- server/dev-server.js | 2 +- server/index.ts | 111 ++++------ tsconfig.json | 2 +- types/env.env.d.ts | 2 + types/remix.env.d.ts | 2 - vite.config.ts | 36 +++ 41 files changed, 1001 insertions(+), 939 deletions(-) create mode 100644 app/routes/_auth+/login.server.ts create mode 100644 app/routes/_auth+/onboarding.server.ts create mode 100644 app/routes/_auth+/onboarding_.$provider.server.ts create mode 100644 app/routes/_auth+/reset-password.server.ts create mode 100644 app/routes/_auth+/verify.server.ts create mode 100644 app/routes/admin+/cache_.sqlite.server.ts create mode 100644 app/routes/settings+/profile.change-email.server.tsx create mode 100644 app/routes/users+/$username_+/__note-editor.server.tsx create mode 100644 docs/decisions/036-vite.md delete mode 100644 remix.config.js create mode 100644 types/env.env.d.ts delete mode 100644 types/remix.env.d.ts create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore index a3667f15..2a7f82db 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,9 @@ node_modules .DS_store /build -/public/build /server-build .env +.cache /prisma/data.db /prisma/data.db-journal diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 827d5f52..40951104 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -8,9 +8,9 @@ import { import { RemixServer } from '@remix-run/react' import * as Sentry from '@sentry/remix' import { isbot } from 'isbot' -import { getInstanceInfo } from 'litefs-js' import { renderToPipeableStream } from 'react-dom/server' import { getEnv, init } from './utils/env.server.ts' +import { getInstanceInfo } from './utils/litefs.server.ts' import { NonceProvider } from './utils/nonce-provider.ts' import { makeTimings } from './utils/timing.server.ts' diff --git a/app/root.tsx b/app/root.tsx index 76ca964b..5b03e25a 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -14,7 +14,6 @@ import { Form, Link, Links, - LiveReload, Meta, Outlet, Scripts, @@ -43,7 +42,7 @@ import { } from './components/ui/dropdown-menu.tsx' import { Icon, href as iconsHref } from './components/ui/icon.tsx' import { EpicToaster } from './components/ui/sonner.tsx' -import tailwindStyleSheetUrl from './styles/tailwind.css' +import tailwindStyleSheetUrl from './styles/tailwind.css?url' import { getUserId, logout } from './utils/auth.server.ts' import { ClientHintCheck, getHints, useHints } from './utils/client-hints.tsx' import { prisma } from './utils/db.server.ts' @@ -211,7 +210,6 @@ function Document({ /> - ) diff --git a/app/routes/_auth+/auth.$provider.callback.ts b/app/routes/_auth+/auth.$provider.callback.ts index f2689b90..6af3b43f 100644 --- a/app/routes/_auth+/auth.$provider.callback.ts +++ b/app/routes/_auth+/auth.$provider.callback.ts @@ -16,12 +16,9 @@ import { redirectWithToast, } from '#app/utils/toast.server.ts' import { verifySessionStorage } from '#app/utils/verification.server.ts' -import { handleNewSession } from './login.tsx' -import { - onboardingEmailSessionKey, - prefilledProfileKey, - providerIdKey, -} from './onboarding_.$provider.tsx' +import { handleNewSession } from './login.server.ts' +import { onboardingEmailSessionKey } from './onboarding.tsx' +import { prefilledProfileKey, providerIdKey } from './onboarding_.$provider.tsx' const destroyRedirectTo = { 'set-cookie': destroyRedirectToHeader } diff --git a/app/routes/_auth+/forgot-password.tsx b/app/routes/_auth+/forgot-password.tsx index 5559b95f..26ec3a4b 100644 --- a/app/routes/_auth+/forgot-password.tsx +++ b/app/routes/_auth+/forgot-password.tsx @@ -17,7 +17,7 @@ import { prisma } from '#app/utils/db.server.ts' import { sendEmail } from '#app/utils/email.server.ts' import { checkHoneypot } from '#app/utils/honeypot.server.ts' import { EmailSchema, UsernameSchema } from '#app/utils/user-validation.ts' -import { prepareVerification } from './verify.tsx' +import { prepareVerification } from './verify.server.ts' const ForgotPasswordSchema = z.object({ usernameOrEmail: z.union([EmailSchema, UsernameSchema]), diff --git a/app/routes/_auth+/login.server.ts b/app/routes/_auth+/login.server.ts new file mode 100644 index 00000000..4d4249a8 --- /dev/null +++ b/app/routes/_auth+/login.server.ts @@ -0,0 +1,158 @@ +import { invariant } from '@epic-web/invariant' +import { redirect } from '@remix-run/node' +import { safeRedirect } from 'remix-utils/safe-redirect' +import { twoFAVerificationType } from '#app/routes/settings+/profile.two-factor.tsx' +import { getUserId, sessionKey } from '#app/utils/auth.server.ts' +import { prisma } from '#app/utils/db.server.ts' +import { combineResponseInits } from '#app/utils/misc.tsx' +import { authSessionStorage } from '#app/utils/session.server.ts' +import { redirectWithToast } from '#app/utils/toast.server.ts' +import { verifySessionStorage } from '#app/utils/verification.server.ts' +import { getRedirectToUrl, type VerifyFunctionArgs } from './verify.server.ts' + +const verifiedTimeKey = 'verified-time' +const unverifiedSessionIdKey = 'unverified-session-id' +const rememberKey = 'remember' + +export async function handleNewSession( + { + request, + session, + redirectTo, + remember, + }: { + request: Request + session: { userId: string; id: string; expirationDate: Date } + redirectTo?: string + remember: boolean + }, + responseInit?: ResponseInit, +) { + const verification = await prisma.verification.findUnique({ + select: { id: true }, + where: { + target_type: { target: session.userId, type: twoFAVerificationType }, + }, + }) + const userHasTwoFactor = Boolean(verification) + + if (userHasTwoFactor) { + const verifySession = await verifySessionStorage.getSession() + verifySession.set(unverifiedSessionIdKey, session.id) + verifySession.set(rememberKey, remember) + const redirectUrl = getRedirectToUrl({ + request, + type: twoFAVerificationType, + target: session.userId, + redirectTo, + }) + return redirect( + `${redirectUrl.pathname}?${redirectUrl.searchParams}`, + combineResponseInits( + { + headers: { + 'set-cookie': + await verifySessionStorage.commitSession(verifySession), + }, + }, + responseInit, + ), + ) + } else { + const authSession = await authSessionStorage.getSession( + request.headers.get('cookie'), + ) + authSession.set(sessionKey, session.id) + + return redirect( + safeRedirect(redirectTo), + combineResponseInits( + { + headers: { + 'set-cookie': await authSessionStorage.commitSession(authSession, { + expires: remember ? session.expirationDate : undefined, + }), + }, + }, + responseInit, + ), + ) + } +} + +export async function handleVerification({ + request, + submission, +}: VerifyFunctionArgs) { + invariant( + submission.status === 'success', + 'Submission should be successful by now', + ) + const authSession = await authSessionStorage.getSession( + request.headers.get('cookie'), + ) + const verifySession = await verifySessionStorage.getSession( + request.headers.get('cookie'), + ) + + const remember = verifySession.get(rememberKey) + const { redirectTo } = submission.value + const headers = new Headers() + authSession.set(verifiedTimeKey, Date.now()) + + const unverifiedSessionId = verifySession.get(unverifiedSessionIdKey) + if (unverifiedSessionId) { + const session = await prisma.session.findUnique({ + select: { expirationDate: true }, + where: { id: unverifiedSessionId }, + }) + if (!session) { + throw await redirectWithToast('/login', { + type: 'error', + title: 'Invalid session', + description: 'Could not find session to verify. Please try again.', + }) + } + authSession.set(sessionKey, unverifiedSessionId) + + headers.append( + 'set-cookie', + await authSessionStorage.commitSession(authSession, { + expires: remember ? session.expirationDate : undefined, + }), + ) + } else { + headers.append( + 'set-cookie', + await authSessionStorage.commitSession(authSession), + ) + } + + headers.append( + 'set-cookie', + await verifySessionStorage.destroySession(verifySession), + ) + + return redirect(safeRedirect(redirectTo), { headers }) +} + +export async function shouldRequestTwoFA(request: Request) { + const authSession = await authSessionStorage.getSession( + request.headers.get('cookie'), + ) + const verifySession = await verifySessionStorage.getSession( + request.headers.get('cookie'), + ) + if (verifySession.has(unverifiedSessionIdKey)) return true + const userId = await getUserId(request) + if (!userId) return false + // if it's over two hours since they last verified, we should request 2FA again + const userHasTwoFA = await prisma.verification.findUnique({ + select: { id: true }, + where: { target_type: { target: userId, type: twoFAVerificationType } }, + }) + if (!userHasTwoFA) return false + const verifiedTime = authSession.get(verifiedTimeKey) ?? new Date(0) + const twoHours = 1000 * 60 * 2 + return Date.now() - verifiedTime > twoHours +} diff --git a/app/routes/_auth+/login.tsx b/app/routes/_auth+/login.tsx index e3edfaf7..0a0c1fe2 100644 --- a/app/routes/_auth+/login.tsx +++ b/app/routes/_auth+/login.tsx @@ -1,187 +1,27 @@ -import { useForm, getFormProps, getInputProps } from '@conform-to/react' +import { getFormProps, getInputProps, useForm } from '@conform-to/react' import { getZodConstraint, parseWithZod } from '@conform-to/zod' -import { invariant } from '@epic-web/invariant' import { json, - redirect, - type LoaderFunctionArgs, type ActionFunctionArgs, + type LoaderFunctionArgs, type MetaFunction, } from '@remix-run/node' import { Form, Link, useActionData, useSearchParams } from '@remix-run/react' import { HoneypotInputs } from 'remix-utils/honeypot/react' -import { safeRedirect } from 'remix-utils/safe-redirect' import { z } from 'zod' import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx' import { CheckboxField, ErrorList, Field } from '#app/components/forms.tsx' import { Spacer } from '#app/components/spacer.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' -import { twoFAVerificationType } from '#app/routes/settings+/profile.two-factor.tsx' -import { - getUserId, - login, - requireAnonymous, - sessionKey, -} from '#app/utils/auth.server.ts' +import { login, requireAnonymous } from '#app/utils/auth.server.ts' import { ProviderConnectionForm, providerNames, } from '#app/utils/connections.tsx' -import { prisma } from '#app/utils/db.server.ts' import { checkHoneypot } from '#app/utils/honeypot.server.ts' -import { combineResponseInits, useIsPending } from '#app/utils/misc.tsx' -import { authSessionStorage } from '#app/utils/session.server.ts' -import { redirectWithToast } from '#app/utils/toast.server.ts' +import { useIsPending } from '#app/utils/misc.tsx' import { PasswordSchema, UsernameSchema } from '#app/utils/user-validation.ts' -import { verifySessionStorage } from '#app/utils/verification.server.ts' -import { getRedirectToUrl, type VerifyFunctionArgs } from './verify.tsx' - -const verifiedTimeKey = 'verified-time' -const unverifiedSessionIdKey = 'unverified-session-id' -const rememberKey = 'remember' - -export async function handleNewSession( - { - request, - session, - redirectTo, - remember, - }: { - request: Request - session: { userId: string; id: string; expirationDate: Date } - redirectTo?: string - remember: boolean - }, - responseInit?: ResponseInit, -) { - const verification = await prisma.verification.findUnique({ - select: { id: true }, - where: { - target_type: { target: session.userId, type: twoFAVerificationType }, - }, - }) - const userHasTwoFactor = Boolean(verification) - - if (userHasTwoFactor) { - const verifySession = await verifySessionStorage.getSession() - verifySession.set(unverifiedSessionIdKey, session.id) - verifySession.set(rememberKey, remember) - const redirectUrl = getRedirectToUrl({ - request, - type: twoFAVerificationType, - target: session.userId, - redirectTo, - }) - return redirect( - `${redirectUrl.pathname}?${redirectUrl.searchParams}`, - combineResponseInits( - { - headers: { - 'set-cookie': - await verifySessionStorage.commitSession(verifySession), - }, - }, - responseInit, - ), - ) - } else { - const authSession = await authSessionStorage.getSession( - request.headers.get('cookie'), - ) - authSession.set(sessionKey, session.id) - - return redirect( - safeRedirect(redirectTo), - combineResponseInits( - { - headers: { - 'set-cookie': await authSessionStorage.commitSession(authSession, { - expires: remember ? session.expirationDate : undefined, - }), - }, - }, - responseInit, - ), - ) - } -} - -export async function handleVerification({ - request, - submission, -}: VerifyFunctionArgs) { - invariant( - submission.status === 'success', - 'Submission should be successful by now', - ) - const authSession = await authSessionStorage.getSession( - request.headers.get('cookie'), - ) - const verifySession = await verifySessionStorage.getSession( - request.headers.get('cookie'), - ) - - const remember = verifySession.get(rememberKey) - const { redirectTo } = submission.value - const headers = new Headers() - authSession.set(verifiedTimeKey, Date.now()) - - const unverifiedSessionId = verifySession.get(unverifiedSessionIdKey) - if (unverifiedSessionId) { - const session = await prisma.session.findUnique({ - select: { expirationDate: true }, - where: { id: unverifiedSessionId }, - }) - if (!session) { - throw await redirectWithToast('/login', { - type: 'error', - title: 'Invalid session', - description: 'Could not find session to verify. Please try again.', - }) - } - authSession.set(sessionKey, unverifiedSessionId) - - headers.append( - 'set-cookie', - await authSessionStorage.commitSession(authSession, { - expires: remember ? session.expirationDate : undefined, - }), - ) - } else { - headers.append( - 'set-cookie', - await authSessionStorage.commitSession(authSession), - ) - } - - headers.append( - 'set-cookie', - await verifySessionStorage.destroySession(verifySession), - ) - - return redirect(safeRedirect(redirectTo), { headers }) -} - -export async function shouldRequestTwoFA(request: Request) { - const authSession = await authSessionStorage.getSession( - request.headers.get('cookie'), - ) - const verifySession = await verifySessionStorage.getSession( - request.headers.get('cookie'), - ) - if (verifySession.has(unverifiedSessionIdKey)) return true - const userId = await getUserId(request) - if (!userId) return false - // if it's over two hours since they last verified, we should request 2FA again - const userHasTwoFA = await prisma.verification.findUnique({ - select: { id: true }, - where: { target_type: { target: userId, type: twoFAVerificationType } }, - }) - if (!userHasTwoFA) return false - const verifiedTime = authSession.get(verifiedTimeKey) ?? new Date(0) - const twoHours = 1000 * 60 * 2 - return Date.now() - verifiedTime > twoHours -} +import { handleNewSession } from './login.server.ts' const LoginFormSchema = z.object({ username: UsernameSchema, diff --git a/app/routes/_auth+/onboarding.server.ts b/app/routes/_auth+/onboarding.server.ts new file mode 100644 index 00000000..826a72a8 --- /dev/null +++ b/app/routes/_auth+/onboarding.server.ts @@ -0,0 +1,19 @@ +import { invariant } from '@epic-web/invariant' +import { redirect } from '@remix-run/node' +import { verifySessionStorage } from '#app/utils/verification.server.ts' +import { onboardingEmailSessionKey } from './onboarding.tsx' +import { type VerifyFunctionArgs } from './verify.server.ts' + +export async function handleVerification({ submission }: VerifyFunctionArgs) { + invariant( + submission.status === 'success', + 'Submission should be successful by now', + ) + const verifySession = await verifySessionStorage.getSession() + verifySession.set(onboardingEmailSessionKey, submission.value.target) + return redirect('/onboarding', { + headers: { + 'set-cookie': await verifySessionStorage.commitSession(verifySession), + }, + }) +} diff --git a/app/routes/_auth+/onboarding.tsx b/app/routes/_auth+/onboarding.tsx index 6752145f..786cec57 100644 --- a/app/routes/_auth+/onboarding.tsx +++ b/app/routes/_auth+/onboarding.tsx @@ -1,6 +1,5 @@ import { getFormProps, getInputProps, useForm } from '@conform-to/react' import { getZodConstraint, parseWithZod } from '@conform-to/zod' -import { invariant } from '@epic-web/invariant' import { json, redirect, @@ -32,9 +31,8 @@ import { UsernameSchema, } from '#app/utils/user-validation.ts' import { verifySessionStorage } from '#app/utils/verification.server.ts' -import { type VerifyFunctionArgs } from './verify.tsx' -const onboardingEmailSessionKey = 'onboardingEmail' +export const onboardingEmailSessionKey = 'onboardingEmail' const SignupFormSchema = z .object({ @@ -60,6 +58,7 @@ async function requireOnboardingEmail(request: Request) { } return email } + export async function loader({ request }: LoaderFunctionArgs) { const email = await requireOnboardingEmail(request) return json({ email }) @@ -126,20 +125,6 @@ export async function action({ request }: ActionFunctionArgs) { ) } -export async function handleVerification({ submission }: VerifyFunctionArgs) { - invariant( - submission.status === 'success', - 'Submission should be successful by now', - ) - const verifySession = await verifySessionStorage.getSession() - verifySession.set(onboardingEmailSessionKey, submission.value.target) - return redirect('/onboarding', { - headers: { - 'set-cookie': await verifySessionStorage.commitSession(verifySession), - }, - }) -} - export const meta: MetaFunction = () => { return [{ title: 'Setup Epic Notes Account' }] } diff --git a/app/routes/_auth+/onboarding_.$provider.server.ts b/app/routes/_auth+/onboarding_.$provider.server.ts new file mode 100644 index 00000000..826a72a8 --- /dev/null +++ b/app/routes/_auth+/onboarding_.$provider.server.ts @@ -0,0 +1,19 @@ +import { invariant } from '@epic-web/invariant' +import { redirect } from '@remix-run/node' +import { verifySessionStorage } from '#app/utils/verification.server.ts' +import { onboardingEmailSessionKey } from './onboarding.tsx' +import { type VerifyFunctionArgs } from './verify.server.ts' + +export async function handleVerification({ submission }: VerifyFunctionArgs) { + invariant( + submission.status === 'success', + 'Submission should be successful by now', + ) + const verifySession = await verifySessionStorage.getSession() + verifySession.set(onboardingEmailSessionKey, submission.value.target) + return redirect('/onboarding', { + headers: { + 'set-cookie': await verifySessionStorage.commitSession(verifySession), + }, + }) +} diff --git a/app/routes/_auth+/onboarding_.$provider.tsx b/app/routes/_auth+/onboarding_.$provider.tsx index be4df71f..411ba8ad 100644 --- a/app/routes/_auth+/onboarding_.$provider.tsx +++ b/app/routes/_auth+/onboarding_.$provider.tsx @@ -1,24 +1,23 @@ import { - type SubmissionResult, getFormProps, getInputProps, useForm, + type SubmissionResult, } from '@conform-to/react' import { getZodConstraint, parseWithZod } from '@conform-to/zod' -import { invariant } from '@epic-web/invariant' import { - json, redirect, - type LoaderFunctionArgs, + json, type ActionFunctionArgs, + type LoaderFunctionArgs, type MetaFunction, } from '@remix-run/node' import { + type Params, Form, useActionData, useLoaderData, useSearchParams, - type Params, } from '@remix-run/react' import { safeRedirect } from 'remix-utils/safe-redirect' import { z } from 'zod' @@ -27,9 +26,9 @@ import { Spacer } from '#app/components/spacer.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' import { authenticator, - requireAnonymous, sessionKey, signupWithConnection, + requireAnonymous, } from '#app/utils/auth.server.ts' import { ProviderNameSchema } from '#app/utils/connections.tsx' import { prisma } from '#app/utils/db.server.ts' @@ -38,9 +37,8 @@ import { authSessionStorage } from '#app/utils/session.server.ts' import { redirectWithToast } from '#app/utils/toast.server.ts' import { NameSchema, UsernameSchema } from '#app/utils/user-validation.ts' import { verifySessionStorage } from '#app/utils/verification.server.ts' -import { type VerifyFunctionArgs } from './verify.tsx' +import { onboardingEmailSessionKey } from './onboarding' -export const onboardingEmailSessionKey = 'onboardingEmail' export const providerIdKey = 'providerId' export const prefilledProfileKey = 'prefilledProfile' @@ -176,20 +174,6 @@ export async function action({ request, params }: ActionFunctionArgs) { ) } -export async function handleVerification({ submission }: VerifyFunctionArgs) { - invariant( - submission.status === 'success', - 'Submission should be successful by now', - ) - const verifySession = await verifySessionStorage.getSession() - verifySession.set(onboardingEmailSessionKey, submission.value.target) - return redirect('/onboarding', { - headers: { - 'set-cookie': await verifySessionStorage.commitSession(verifySession), - }, - }) -} - export const meta: MetaFunction = () => { return [{ title: 'Setup Epic Notes Account' }] } diff --git a/app/routes/_auth+/reset-password.server.ts b/app/routes/_auth+/reset-password.server.ts new file mode 100644 index 00000000..1c25b4dc --- /dev/null +++ b/app/routes/_auth+/reset-password.server.ts @@ -0,0 +1,34 @@ +import { invariant } from '@epic-web/invariant' +import { json, redirect } from '@remix-run/node' +import { prisma } from '#app/utils/db.server.ts' +import { verifySessionStorage } from '#app/utils/verification.server.ts' +import { resetPasswordUsernameSessionKey } from './reset-password.tsx' +import { type VerifyFunctionArgs } from './verify.server.ts' + +export async function handleVerification({ submission }: VerifyFunctionArgs) { + invariant( + submission.status === 'success', + 'Submission should be successful by now', + ) + const target = submission.value.target + const user = await prisma.user.findFirst({ + where: { OR: [{ email: target }, { username: target }] }, + select: { email: true, username: true }, + }) + // we don't want to say the user is not found if the email is not found + // because that would allow an attacker to check if an email is registered + if (!user) { + return json( + { result: submission.reply({ fieldErrors: { code: ['Invalid code'] } }) }, + { status: 400 }, + ) + } + + const verifySession = await verifySessionStorage.getSession() + verifySession.set(resetPasswordUsernameSessionKey, user.username) + return redirect('/reset-password', { + headers: { + 'set-cookie': await verifySessionStorage.commitSession(verifySession), + }, + }) +} diff --git a/app/routes/_auth+/reset-password.tsx b/app/routes/_auth+/reset-password.tsx index 0428bd6f..9a9b4259 100644 --- a/app/routes/_auth+/reset-password.tsx +++ b/app/routes/_auth+/reset-password.tsx @@ -1,11 +1,10 @@ import { getFormProps, getInputProps, useForm } from '@conform-to/react' import { getZodConstraint, parseWithZod } from '@conform-to/zod' -import { invariant } from '@epic-web/invariant' import { json, redirect, - type LoaderFunctionArgs, type ActionFunctionArgs, + type LoaderFunctionArgs, type MetaFunction, } from '@remix-run/node' import { Form, useActionData, useLoaderData } from '@remix-run/react' @@ -13,45 +12,11 @@ import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx' import { ErrorList, Field } from '#app/components/forms.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' import { requireAnonymous, resetUserPassword } from '#app/utils/auth.server.ts' -import { prisma } from '#app/utils/db.server.ts' import { useIsPending } from '#app/utils/misc.tsx' import { PasswordAndConfirmPasswordSchema } from '#app/utils/user-validation.ts' import { verifySessionStorage } from '#app/utils/verification.server.ts' -import { type VerifyFunctionArgs } from './verify.tsx' - -const resetPasswordUsernameSessionKey = 'resetPasswordUsername' - -export async function handleVerification({ submission }: VerifyFunctionArgs) { - invariant( - submission.status === 'success', - 'Submission should be successful by now', - ) - const target = submission.value.target - const user = await prisma.user.findFirst({ - where: { OR: [{ email: target }, { username: target }] }, - select: { email: true, username: true }, - }) - // we don't want to say the user is not found if the email is not found - // because that would allow an attacker to check if an email is registered - if (!user) { - return json( - { - result: submission.reply({ fieldErrors: { code: ['Invalid code'] } }), - }, - { - status: 400, - }, - ) - } - const verifySession = await verifySessionStorage.getSession() - verifySession.set(resetPasswordUsernameSessionKey, user.username) - return redirect('/reset-password', { - headers: { - 'set-cookie': await verifySessionStorage.commitSession(verifySession), - }, - }) -} +export const resetPasswordUsernameSessionKey = 'resetPasswordUsername' const ResetPasswordSchema = PasswordAndConfirmPasswordSchema diff --git a/app/routes/_auth+/signup.tsx b/app/routes/_auth+/signup.tsx index 0303877c..14c71c3d 100644 --- a/app/routes/_auth+/signup.tsx +++ b/app/routes/_auth+/signup.tsx @@ -22,7 +22,7 @@ import { sendEmail } from '#app/utils/email.server.ts' import { checkHoneypot } from '#app/utils/honeypot.server.ts' import { useIsPending } from '#app/utils/misc.tsx' import { EmailSchema } from '#app/utils/user-validation.ts' -import { prepareVerification } from './verify.tsx' +import { prepareVerification } from './verify.server.ts' const SignupSchema = z.object({ email: EmailSchema, diff --git a/app/routes/_auth+/verify.server.ts b/app/routes/_auth+/verify.server.ts new file mode 100644 index 00000000..c7f53fa1 --- /dev/null +++ b/app/routes/_auth+/verify.server.ts @@ -0,0 +1,205 @@ +import { type Submission } from '@conform-to/react' +import { parseWithZod } from '@conform-to/zod' +import { json } from '@remix-run/node' +import { z } from 'zod' +import { handleVerification as handleChangeEmailVerification } from '#app/routes/settings+/profile.change-email.server.tsx' +import { twoFAVerificationType } from '#app/routes/settings+/profile.two-factor.tsx' +import { requireUserId } from '#app/utils/auth.server.ts' +import { prisma } from '#app/utils/db.server.ts' +import { ensurePrimary } from '#app/utils/litefs.server.ts' +import { getDomainUrl } from '#app/utils/misc.tsx' +import { redirectWithToast } from '#app/utils/toast.server.ts' +import { generateTOTP, verifyTOTP } from '#app/utils/totp.server.ts' +import { type twoFAVerifyVerificationType } from '../settings+/profile.two-factor.verify.tsx' +import { + handleVerification as handleLoginTwoFactorVerification, + shouldRequestTwoFA, +} from './login.server.ts' +import { handleVerification as handleOnboardingVerification } from './onboarding.server.ts' +import { handleVerification as handleResetPasswordVerification } from './reset-password.server.ts' +import { + VerifySchema, + codeQueryParam, + redirectToQueryParam, + targetQueryParam, + typeQueryParam, + type VerificationTypes, +} from './verify.tsx' + +export type VerifyFunctionArgs = { + request: Request + submission: Submission< + z.input, + string[], + z.output + > + body: FormData | URLSearchParams +} + +export function getRedirectToUrl({ + request, + type, + target, + redirectTo, +}: { + request: Request + type: VerificationTypes + target: string + redirectTo?: string +}) { + const redirectToUrl = new URL(`${getDomainUrl(request)}/verify`) + redirectToUrl.searchParams.set(typeQueryParam, type) + redirectToUrl.searchParams.set(targetQueryParam, target) + if (redirectTo) { + redirectToUrl.searchParams.set(redirectToQueryParam, redirectTo) + } + return redirectToUrl +} + +export async function requireRecentVerification(request: Request) { + const userId = await requireUserId(request) + const shouldReverify = await shouldRequestTwoFA(request) + if (shouldReverify) { + const reqUrl = new URL(request.url) + const redirectUrl = getRedirectToUrl({ + request, + target: userId, + type: twoFAVerificationType, + redirectTo: reqUrl.pathname + reqUrl.search, + }) + throw await redirectWithToast(redirectUrl.toString(), { + title: 'Please Reverify', + description: 'Please reverify your account before proceeding', + }) + } +} + +export async function prepareVerification({ + period, + request, + type, + target, +}: { + period: number + request: Request + type: VerificationTypes + target: string +}) { + const verifyUrl = getRedirectToUrl({ request, type, target }) + const redirectTo = new URL(verifyUrl.toString()) + + const { otp, ...verificationConfig } = generateTOTP({ + algorithm: 'SHA256', + // Leaving off 0 and O on purpose to avoid confusing users. + charSet: 'ABCDEFGHIJKLMNPQRSTUVWXYZ123456789', + period, + }) + const verificationData = { + type, + target, + ...verificationConfig, + expiresAt: new Date(Date.now() + verificationConfig.period * 1000), + } + await prisma.verification.upsert({ + where: { target_type: { target, type } }, + create: verificationData, + update: verificationData, + }) + + // add the otp to the url we'll email the user. + verifyUrl.searchParams.set(codeQueryParam, otp) + + return { otp, redirectTo, verifyUrl } +} + +export async function isCodeValid({ + code, + type, + target, +}: { + code: string + type: VerificationTypes | typeof twoFAVerifyVerificationType + target: string +}) { + const verification = await prisma.verification.findUnique({ + where: { + target_type: { target, type }, + OR: [{ expiresAt: { gt: new Date() } }, { expiresAt: null }], + }, + select: { algorithm: true, secret: true, period: true, charSet: true }, + }) + if (!verification) return false + const result = verifyTOTP({ + otp: code, + ...verification, + }) + if (!result) return false + + return true +} + +export async function validateRequest( + request: Request, + body: URLSearchParams | FormData, +) { + const submission = await parseWithZod(body, { + schema: VerifySchema.superRefine(async (data, ctx) => { + const codeIsValid = await isCodeValid({ + code: data[codeQueryParam], + type: data[typeQueryParam], + target: data[targetQueryParam], + }) + if (!codeIsValid) { + ctx.addIssue({ + path: ['code'], + code: z.ZodIssueCode.custom, + message: `Invalid code`, + }) + return + } + }), + async: true, + }) + + if (submission.status !== 'success') { + return json( + { result: submission.reply() }, + { status: submission.status === 'error' ? 400 : 200 }, + ) + } + + // this code path could be part of a loader (GET request), so we need to make + // sure we're running on primary because we're about to make writes. + await ensurePrimary() + + const { value: submissionValue } = submission + + async function deleteVerification() { + await prisma.verification.delete({ + where: { + target_type: { + type: submissionValue[typeQueryParam], + target: submissionValue[targetQueryParam], + }, + }, + }) + } + + switch (submissionValue[typeQueryParam]) { + case 'reset-password': { + await deleteVerification() + return handleResetPasswordVerification({ request, body, submission }) + } + case 'onboarding': { + await deleteVerification() + return handleOnboardingVerification({ request, body, submission }) + } + case 'change-email': { + await deleteVerification() + return handleChangeEmailVerification({ request, body, submission }) + } + case '2fa': { + return handleLoginTwoFactorVerification({ request, body, submission }) + } + } +} diff --git a/app/routes/_auth+/verify.tsx b/app/routes/_auth+/verify.tsx index 0612822a..287a01c5 100644 --- a/app/routes/_auth+/verify.tsx +++ b/app/routes/_auth+/verify.tsx @@ -1,11 +1,6 @@ -import { - useForm, - type Submission, - getFormProps, - getInputProps, -} from '@conform-to/react' +import { getFormProps, getInputProps, useForm } from '@conform-to/react' import { getZodConstraint, parseWithZod } from '@conform-to/zod' -import { json, type ActionFunctionArgs } from '@remix-run/node' +import { type ActionFunctionArgs } from '@remix-run/node' import { Form, useActionData, useSearchParams } from '@remix-run/react' import { HoneypotInputs } from 'remix-utils/honeypot/react' import { z } from 'zod' @@ -13,22 +8,9 @@ import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx' import { ErrorList, Field } from '#app/components/forms.tsx' import { Spacer } from '#app/components/spacer.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' -import { handleVerification as handleChangeEmailVerification } from '#app/routes/settings+/profile.change-email.tsx' -import { twoFAVerificationType } from '#app/routes/settings+/profile.two-factor.tsx' -import { type twoFAVerifyVerificationType } from '#app/routes/settings+/profile.two-factor.verify.tsx' -import { requireUserId } from '#app/utils/auth.server.ts' -import { prisma } from '#app/utils/db.server.ts' import { checkHoneypot } from '#app/utils/honeypot.server.ts' -import { ensurePrimary } from '#app/utils/litefs.server.ts' -import { getDomainUrl, useIsPending } from '#app/utils/misc.tsx' -import { redirectWithToast } from '#app/utils/toast.server.ts' -import { generateTOTP, verifyTOTP } from '#app/utils/totp.server.ts' -import { - handleVerification as handleLoginTwoFactorVerification, - shouldRequestTwoFA, -} from './login.tsx' -import { handleVerification as handleOnboardingVerification } from './onboarding.tsx' -import { handleVerification as handleResetPasswordVerification } from './reset-password.tsx' +import { useIsPending } from '#app/utils/misc.tsx' +import { validateRequest } from './verify.server.ts' export const codeQueryParam = 'code' export const targetQueryParam = 'target' @@ -38,7 +20,7 @@ const types = ['onboarding', 'reset-password', 'change-email', '2fa'] as const const VerificationTypeSchema = z.enum(types) export type VerificationTypes = z.infer -const VerifySchema = z.object({ +export const VerifySchema = z.object({ [codeQueryParam]: z.string().min(6).max(6), [typeQueryParam]: VerificationTypeSchema, [targetQueryParam]: z.string(), @@ -51,184 +33,6 @@ export async function action({ request }: ActionFunctionArgs) { return validateRequest(request, formData) } -export function getRedirectToUrl({ - request, - type, - target, - redirectTo, -}: { - request: Request - type: VerificationTypes - target: string - redirectTo?: string -}) { - const redirectToUrl = new URL(`${getDomainUrl(request)}/verify`) - redirectToUrl.searchParams.set(typeQueryParam, type) - redirectToUrl.searchParams.set(targetQueryParam, target) - if (redirectTo) { - redirectToUrl.searchParams.set(redirectToQueryParam, redirectTo) - } - return redirectToUrl -} - -export async function requireRecentVerification(request: Request) { - const userId = await requireUserId(request) - const shouldReverify = await shouldRequestTwoFA(request) - if (shouldReverify) { - const reqUrl = new URL(request.url) - const redirectUrl = getRedirectToUrl({ - request, - target: userId, - type: twoFAVerificationType, - redirectTo: reqUrl.pathname + reqUrl.search, - }) - throw await redirectWithToast(redirectUrl.toString(), { - title: 'Please Reverify', - description: 'Please reverify your account before proceeding', - }) - } -} - -export async function prepareVerification({ - period, - request, - type, - target, -}: { - period: number - request: Request - type: VerificationTypes - target: string -}) { - const verifyUrl = getRedirectToUrl({ request, type, target }) - const redirectTo = new URL(verifyUrl.toString()) - - const { otp, ...verificationConfig } = generateTOTP({ - algorithm: 'SHA256', - // Leaving off 0 and O on purpose to avoid confusing users. - charSet: 'ABCDEFGHIJKLMNPQRSTUVWXYZ123456789', - period, - }) - const verificationData = { - type, - target, - ...verificationConfig, - expiresAt: new Date(Date.now() + verificationConfig.period * 1000), - } - await prisma.verification.upsert({ - where: { target_type: { target, type } }, - create: verificationData, - update: verificationData, - }) - - // add the otp to the url we'll email the user. - verifyUrl.searchParams.set(codeQueryParam, otp) - - return { otp, redirectTo, verifyUrl } -} - -export type VerifyFunctionArgs = { - request: Request - submission: Submission< - z.input, - string[], - z.output - > - body: FormData | URLSearchParams -} - -export async function isCodeValid({ - code, - type, - target, -}: { - code: string - type: VerificationTypes | typeof twoFAVerifyVerificationType - target: string -}) { - const verification = await prisma.verification.findUnique({ - where: { - target_type: { target, type }, - OR: [{ expiresAt: { gt: new Date() } }, { expiresAt: null }], - }, - select: { algorithm: true, secret: true, period: true, charSet: true }, - }) - if (!verification) return false - const result = verifyTOTP({ - otp: code, - ...verification, - }) - if (!result) return false - - return true -} - -async function validateRequest( - request: Request, - body: URLSearchParams | FormData, -) { - const submission = await parseWithZod(body, { - schema: VerifySchema.superRefine(async (data, ctx) => { - const codeIsValid = await isCodeValid({ - code: data[codeQueryParam], - type: data[typeQueryParam], - target: data[targetQueryParam], - }) - if (!codeIsValid) { - ctx.addIssue({ - path: ['code'], - code: z.ZodIssueCode.custom, - message: `Invalid code`, - }) - return - } - }), - async: true, - }) - - if (submission.status !== 'success') { - return json( - { result: submission.reply() }, - { status: submission.status === 'error' ? 400 : 200 }, - ) - } - - // this code path could be part of a loader (GET request), so we need to make - // sure we're running on primary because we're about to make writes. - await ensurePrimary() - - const { value: submissionValue } = submission - - async function deleteVerification() { - await prisma.verification.delete({ - where: { - target_type: { - type: submissionValue[typeQueryParam], - target: submissionValue[targetQueryParam], - }, - }, - }) - } - - switch (submissionValue[typeQueryParam]) { - case 'reset-password': { - await deleteVerification() - return handleResetPasswordVerification({ request, body, submission }) - } - case 'onboarding': { - await deleteVerification() - return handleOnboardingVerification({ request, body, submission }) - } - case 'change-email': { - await deleteVerification() - return handleChangeEmailVerification({ request, body, submission }) - } - case '2fa': { - return handleLoginTwoFactorVerification({ request, body, submission }) - } - } -} - export default function VerifyRoute() { const [searchParams] = useSearchParams() const isPending = useIsPending() diff --git a/app/routes/_seo+/sitemap[.]xml.ts b/app/routes/_seo+/sitemap[.]xml.ts index bd553e02..22721c36 100644 --- a/app/routes/_seo+/sitemap[.]xml.ts +++ b/app/routes/_seo+/sitemap[.]xml.ts @@ -1,10 +1,10 @@ import { generateSitemap } from '@nasa-gcn/remix-seo' -import { routes } from '@remix-run/dev/server-build' -import { type LoaderFunctionArgs } from '@remix-run/node' +import { type ServerBuild, type LoaderFunctionArgs } from '@remix-run/node' import { getDomainUrl } from '#app/utils/misc.tsx' -export function loader({ request }: LoaderFunctionArgs) { - return generateSitemap(request, routes, { +export async function loader({ request, context }: LoaderFunctionArgs) { + const serverBuild = (await context.serverBuild) as ServerBuild + return generateSitemap(request, serverBuild.routes, { siteUrl: getDomainUrl(request), headers: { 'Cache-Control': `public, max-age=${60 * 5}`, diff --git a/app/routes/admin+/cache_.lru.$cacheKey.ts b/app/routes/admin+/cache_.lru.$cacheKey.ts index 5083fd9f..2b793d09 100644 --- a/app/routes/admin+/cache_.lru.$cacheKey.ts +++ b/app/routes/admin+/cache_.lru.$cacheKey.ts @@ -1,8 +1,11 @@ import { invariantResponse } from '@epic-web/invariant' import { json, type LoaderFunctionArgs } from '@remix-run/node' -import { getAllInstances, getInstanceInfo } from 'litefs-js' -import { ensureInstance } from 'litefs-js/remix.js' import { lruCache } from '#app/utils/cache.server.ts' +import { + getAllInstances, + getInstanceInfo, + ensureInstance, +} from '#app/utils/litefs.server.ts' import { requireUserWithRole } from '#app/utils/permissions.server.ts' export async function loader({ request, params }: LoaderFunctionArgs) { diff --git a/app/routes/admin+/cache_.sqlite.$cacheKey.ts b/app/routes/admin+/cache_.sqlite.$cacheKey.ts index bd1ae37a..39bcb077 100644 --- a/app/routes/admin+/cache_.sqlite.$cacheKey.ts +++ b/app/routes/admin+/cache_.sqlite.$cacheKey.ts @@ -1,8 +1,11 @@ import { invariantResponse } from '@epic-web/invariant' import { json, type LoaderFunctionArgs } from '@remix-run/node' -import { getAllInstances, getInstanceInfo } from 'litefs-js' -import { ensureInstance } from 'litefs-js/remix.js' import { cache } from '#app/utils/cache.server.ts' +import { + getAllInstances, + getInstanceInfo, + ensureInstance, +} from '#app/utils/litefs.server.ts' import { requireUserWithRole } from '#app/utils/permissions.server.ts' export async function loader({ request, params }: LoaderFunctionArgs) { diff --git a/app/routes/admin+/cache_.sqlite.server.ts b/app/routes/admin+/cache_.sqlite.server.ts new file mode 100644 index 00000000..314f9300 --- /dev/null +++ b/app/routes/admin+/cache_.sqlite.server.ts @@ -0,0 +1,29 @@ +import { + getInstanceInfo, + getInternalInstanceDomain, +} from '#app/utils/litefs.server' + +export async function updatePrimaryCacheValue({ + key, + cacheValue, +}: { + key: string + cacheValue: any +}) { + const { currentIsPrimary, primaryInstance } = await getInstanceInfo() + if (currentIsPrimary) { + throw new Error( + `updatePrimaryCacheValue should not be called on the primary instance (${primaryInstance})}`, + ) + } + const domain = getInternalInstanceDomain(primaryInstance) + const token = process.env.INTERNAL_COMMAND_TOKEN + return fetch(`${domain}/admin/cache/sqlite`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ key, cacheValue }), + }) +} diff --git a/app/routes/admin+/cache_.sqlite.tsx b/app/routes/admin+/cache_.sqlite.tsx index 6eef0133..68b48ac7 100644 --- a/app/routes/admin+/cache_.sqlite.tsx +++ b/app/routes/admin+/cache_.sqlite.tsx @@ -1,7 +1,7 @@ -import { type ActionFunctionArgs, json, redirect } from '@remix-run/node' -import { getInstanceInfo, getInternalInstanceDomain } from 'litefs-js' +import { json, redirect, type ActionFunctionArgs } from '@remix-run/node' import { z } from 'zod' import { cache } from '#app/utils/cache.server.ts' +import { getInstanceInfo } from '#app/utils/litefs.server' export async function action({ request }: ActionFunctionArgs) { const { currentIsPrimary, primaryInstance } = await getInstanceInfo() @@ -28,28 +28,3 @@ export async function action({ request }: ActionFunctionArgs) { } return json({ success: true }) } - -export async function updatePrimaryCacheValue({ - key, - cacheValue, -}: { - key: string - cacheValue: any -}) { - const { currentIsPrimary, primaryInstance } = await getInstanceInfo() - if (currentIsPrimary) { - throw new Error( - `updatePrimaryCacheValue should not be called on the primary instance (${primaryInstance})}`, - ) - } - const domain = getInternalInstanceDomain(primaryInstance) - const token = process.env.INTERNAL_COMMAND_TOKEN - return fetch(`${domain}/admin/cache/sqlite`, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ key, cacheValue }), - }) -} diff --git a/app/routes/settings+/profile.change-email.server.tsx b/app/routes/settings+/profile.change-email.server.tsx new file mode 100644 index 00000000..0a0eebc0 --- /dev/null +++ b/app/routes/settings+/profile.change-email.server.tsx @@ -0,0 +1,124 @@ +import { invariant } from '@epic-web/invariant' +import * as E from '@react-email/components' +import { json } from '@remix-run/node' +import { + requireRecentVerification, + type VerifyFunctionArgs, +} from '#app/routes/_auth+/verify.server.ts' +import { prisma } from '#app/utils/db.server.ts' +import { sendEmail } from '#app/utils/email.server.ts' +import { redirectWithToast } from '#app/utils/toast.server.ts' +import { verifySessionStorage } from '#app/utils/verification.server.ts' +import { newEmailAddressSessionKey } from './profile.change-email' + +export async function handleVerification({ + request, + submission, +}: VerifyFunctionArgs) { + await requireRecentVerification(request) + invariant( + submission.status === 'success', + 'Submission should be successful by now', + ) + + const verifySession = await verifySessionStorage.getSession( + request.headers.get('cookie'), + ) + const newEmail = verifySession.get(newEmailAddressSessionKey) + if (!newEmail) { + return json( + { + result: submission.reply({ + formErrors: [ + 'You must submit the code on the same device that requested the email change.', + ], + }), + }, + { status: 400 }, + ) + } + const preUpdateUser = await prisma.user.findFirstOrThrow({ + select: { email: true }, + where: { id: submission.value.target }, + }) + const user = await prisma.user.update({ + where: { id: submission.value.target }, + select: { id: true, email: true, username: true }, + data: { email: newEmail }, + }) + + void sendEmail({ + to: preUpdateUser.email, + subject: 'Epic Stack email changed', + react: , + }) + + return redirectWithToast( + '/settings/profile', + { + title: 'Email Changed', + type: 'success', + description: `Your email has been changed to ${user.email}`, + }, + { + headers: { + 'set-cookie': await verifySessionStorage.destroySession(verifySession), + }, + }, + ) +} + +export function EmailChangeEmail({ + verifyUrl, + otp, +}: { + verifyUrl: string + otp: string +}) { + return ( + + +

+ Epic Notes Email Change +

+

+ + Here's your verification code: {otp} + +

+

+ Or click the link: +

+ {verifyUrl} +
+
+ ) +} + +function EmailChangeNoticeEmail({ userId }: { userId: string }) { + return ( + + +

+ Your Epic Notes email has been changed +

+

+ + We're writing to let you know that your Epic Notes email has been + changed. + +

+

+ + If you changed your email address, then you can safely ignore this. + But if you did not change your email address, then please contact + support immediately. + +

+

+ Your Account ID: {userId} +

+
+
+ ) +} diff --git a/app/routes/settings+/profile.change-email.tsx b/app/routes/settings+/profile.change-email.tsx index 03db9f4e..b666fd98 100644 --- a/app/routes/settings+/profile.change-email.tsx +++ b/app/routes/settings+/profile.change-email.tsx @@ -1,13 +1,11 @@ import { getFormProps, getInputProps, useForm } from '@conform-to/react' import { getZodConstraint, parseWithZod } from '@conform-to/zod' -import { invariant } from '@epic-web/invariant' import { type SEOHandle } from '@nasa-gcn/remix-seo' -import * as E from '@react-email/components' import { json, redirect, - type LoaderFunctionArgs, type ActionFunctionArgs, + type LoaderFunctionArgs, } from '@remix-run/node' import { Form, useActionData, useLoaderData } from '@remix-run/react' import { z } from 'zod' @@ -17,15 +15,14 @@ import { StatusButton } from '#app/components/ui/status-button.tsx' import { prepareVerification, requireRecentVerification, - type VerifyFunctionArgs, -} from '#app/routes/_auth+/verify.tsx' +} from '#app/routes/_auth+/verify.server.ts' import { requireUserId } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' import { sendEmail } from '#app/utils/email.server.ts' import { useIsPending } from '#app/utils/misc.tsx' -import { redirectWithToast } from '#app/utils/toast.server.ts' import { EmailSchema } from '#app/utils/user-validation.ts' import { verifySessionStorage } from '#app/utils/verification.server.ts' +import { EmailChangeEmail } from './profile.change-email.server.tsx' import { type BreadcrumbHandle } from './profile.tsx' export const handle: BreadcrumbHandle & SEOHandle = { @@ -33,64 +30,7 @@ export const handle: BreadcrumbHandle & SEOHandle = { getSitemapEntries: () => null, } -const newEmailAddressSessionKey = 'new-email-address' - -export async function handleVerification({ - request, - submission, -}: VerifyFunctionArgs) { - await requireRecentVerification(request) - invariant( - submission.status === 'success', - 'Submission should be successful by now', - ) - - const verifySession = await verifySessionStorage.getSession( - request.headers.get('cookie'), - ) - const newEmail = verifySession.get(newEmailAddressSessionKey) - if (!newEmail) { - return json( - { - result: submission.reply({ - formErrors: [ - 'You must submit the code on the same device that requested the email change.', - ], - }), - }, - { status: 400 }, - ) - } - const preUpdateUser = await prisma.user.findFirstOrThrow({ - select: { email: true }, - where: { id: submission.value.target }, - }) - const user = await prisma.user.update({ - where: { id: submission.value.target }, - select: { id: true, email: true, username: true }, - data: { email: newEmail }, - }) - - void sendEmail({ - to: preUpdateUser.email, - subject: 'Epic Stack email changed', - react: , - }) - - return redirectWithToast( - '/settings/profile', - { - title: 'Email Changed', - type: 'success', - description: `Your email has been changed to ${user.email}`, - }, - { - headers: { - 'set-cookie': await verifySessionStorage.destroySession(verifySession), - }, - }, - ) -} +export const newEmailAddressSessionKey = 'new-email-address' const ChangeEmailSchema = z.object({ email: EmailSchema, @@ -158,71 +98,12 @@ export async function action({ request }: ActionFunctionArgs) { }) } else { return json( - { - result: submission.reply({ formErrors: [response.error.message] }), - }, - { - status: 500, - }, + { result: submission.reply({ formErrors: [response.error.message] }) }, + { status: 500 }, ) } } -export function EmailChangeEmail({ - verifyUrl, - otp, -}: { - verifyUrl: string - otp: string -}) { - return ( - - -

- Epic Notes Email Change -

-

- - Here's your verification code: {otp} - -

-

- Or click the link: -

- {verifyUrl} -
-
- ) -} - -export function EmailChangeNoticeEmail({ userId }: { userId: string }) { - return ( - - -

- Your Epic Notes email has been changed -

-

- - We're writing to let you know that your Epic Notes email has been - changed. - -

-

- - If you changed your email address, then you can safely ignore this. - But if you did not change your email address, then please contact - support immediately. - -

-

- Your Account ID: {userId} -

-
-
- ) -} - export default function ChangeEmailIndex() { const data = useLoaderData() const actionData = useActionData() diff --git a/app/routes/settings+/profile.two-factor.disable.tsx b/app/routes/settings+/profile.two-factor.disable.tsx index 8805b586..f1530991 100644 --- a/app/routes/settings+/profile.two-factor.disable.tsx +++ b/app/routes/settings+/profile.two-factor.disable.tsx @@ -7,7 +7,7 @@ import { import { useFetcher } from '@remix-run/react' import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' -import { requireRecentVerification } from '#app/routes/_auth+/verify.tsx' +import { requireRecentVerification } from '#app/routes/_auth+/verify.server.ts' import { requireUserId } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' import { useDoubleCheck } from '#app/utils/misc.tsx' diff --git a/app/routes/settings+/profile.two-factor.verify.tsx b/app/routes/settings+/profile.two-factor.verify.tsx index ba3adf96..30742e45 100644 --- a/app/routes/settings+/profile.two-factor.verify.tsx +++ b/app/routes/settings+/profile.two-factor.verify.tsx @@ -18,7 +18,7 @@ import { z } from 'zod' import { ErrorList, Field } from '#app/components/forms.tsx' import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' -import { isCodeValid } from '#app/routes/_auth+/verify.tsx' +import { isCodeValid } from '#app/routes/_auth+/verify.server.ts' import { requireUserId } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' import { getDomainUrl, useIsPending } from '#app/utils/misc.tsx' diff --git a/app/routes/users+/$username_+/__note-editor.server.tsx b/app/routes/users+/$username_+/__note-editor.server.tsx new file mode 100644 index 00000000..6b16253d --- /dev/null +++ b/app/routes/users+/$username_+/__note-editor.server.tsx @@ -0,0 +1,131 @@ +import { parseWithZod } from '@conform-to/zod' +import { createId as cuid } from '@paralleldrive/cuid2' +import { + unstable_createMemoryUploadHandler as createMemoryUploadHandler, + json, + unstable_parseMultipartFormData as parseMultipartFormData, + redirect, + type ActionFunctionArgs, +} from '@remix-run/node' +import { z } from 'zod' +import { requireUserId } from '#app/utils/auth.server.ts' +import { prisma } from '#app/utils/db.server.ts' +import { + MAX_UPLOAD_SIZE, + NoteEditorSchema, + type ImageFieldset, +} from './__note-editor' + +function imageHasFile( + image: ImageFieldset, +): image is ImageFieldset & { file: NonNullable } { + return Boolean(image.file?.size && image.file?.size > 0) +} + +function imageHasId( + image: ImageFieldset, +): image is ImageFieldset & { id: NonNullable } { + return image.id != null +} + +export async function action({ request }: ActionFunctionArgs) { + const userId = await requireUserId(request) + + const formData = await parseMultipartFormData( + request, + createMemoryUploadHandler({ maxPartSize: MAX_UPLOAD_SIZE }), + ) + + const submission = await parseWithZod(formData, { + schema: NoteEditorSchema.superRefine(async (data, ctx) => { + if (!data.id) return + + const note = await prisma.note.findUnique({ + select: { id: true }, + where: { id: data.id, ownerId: userId }, + }) + if (!note) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Note not found', + }) + } + }).transform(async ({ images = [], ...data }) => { + return { + ...data, + imageUpdates: await Promise.all( + images.filter(imageHasId).map(async i => { + if (imageHasFile(i)) { + return { + id: i.id, + altText: i.altText, + contentType: i.file.type, + blob: Buffer.from(await i.file.arrayBuffer()), + } + } else { + return { + id: i.id, + altText: i.altText, + } + } + }), + ), + newImages: await Promise.all( + images + .filter(imageHasFile) + .filter(i => !i.id) + .map(async image => { + return { + altText: image.altText, + contentType: image.file.type, + blob: Buffer.from(await image.file.arrayBuffer()), + } + }), + ), + } + }), + async: true, + }) + + if (submission.status !== 'success') { + return json( + { result: submission.reply() }, + { status: submission.status === 'error' ? 400 : 200 }, + ) + } + + const { + id: noteId, + title, + content, + imageUpdates = [], + newImages = [], + } = submission.value + + const updatedNote = await prisma.note.upsert({ + select: { id: true, owner: { select: { username: true } } }, + where: { id: noteId ?? '__new_note__' }, + create: { + ownerId: userId, + title, + content, + images: { create: newImages }, + }, + update: { + title, + content, + images: { + deleteMany: { id: { notIn: imageUpdates.map(i => i.id) } }, + updateMany: imageUpdates.map(updates => ({ + where: { id: updates.id }, + data: { ...updates, id: updates.blob ? cuid() : updates.id }, + })), + create: newImages, + }, + }, + }) + + return redirect( + `/users/${updatedNote.owner.username}/notes/${updatedNote.id}`, + ) +} diff --git a/app/routes/users+/$username_+/__note-editor.tsx b/app/routes/users+/$username_+/__note-editor.tsx index 7d316585..f5ce8b34 100644 --- a/app/routes/users+/$username_+/__note-editor.tsx +++ b/app/routes/users+/$username_+/__note-editor.tsx @@ -1,23 +1,15 @@ import { - type FieldMetadata, - useForm, + FormProvider, + getFieldsetProps, + getFormProps, getInputProps, getTextareaProps, - getFormProps, - getFieldsetProps, - FormProvider, + useForm, + type FieldMetadata, } from '@conform-to/react' import { getZodConstraint, parseWithZod } from '@conform-to/zod' -import { createId as cuid } from '@paralleldrive/cuid2' import { type Note, type NoteImage } from '@prisma/client' -import { - unstable_createMemoryUploadHandler as createMemoryUploadHandler, - json, - unstable_parseMultipartFormData as parseMultipartFormData, - redirect, - type ActionFunctionArgs, - type SerializeFrom, -} from '@remix-run/node' +import { type SerializeFrom } from '@remix-run/node' import { Form, useActionData } from '@remix-run/react' import { useState } from 'react' import { z } from 'zod' @@ -29,16 +21,15 @@ import { Icon } from '#app/components/ui/icon.tsx' import { Label } from '#app/components/ui/label.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' import { Textarea } from '#app/components/ui/textarea.tsx' -import { requireUserId } from '#app/utils/auth.server.ts' -import { prisma } from '#app/utils/db.server.ts' import { cn, getNoteImgSrc, useIsPending } from '#app/utils/misc.tsx' +import { type action } from './__note-editor.server' const titleMinLength = 1 const titleMaxLength = 100 const contentMinLength = 1 const contentMaxLength = 10000 -const MAX_UPLOAD_SIZE = 1024 * 1024 * 3 // 3MB +export const MAX_UPLOAD_SIZE = 1024 * 1024 * 3 // 3MB const ImageFieldsetSchema = z.object({ id: z.string().optional(), @@ -51,129 +42,15 @@ const ImageFieldsetSchema = z.object({ altText: z.string().optional(), }) -type ImageFieldset = z.infer - -function imageHasFile( - image: ImageFieldset, -): image is ImageFieldset & { file: NonNullable } { - return Boolean(image.file?.size && image.file?.size > 0) -} - -function imageHasId( - image: ImageFieldset, -): image is ImageFieldset & { id: NonNullable } { - return image.id != null -} +export type ImageFieldset = z.infer -const NoteEditorSchema = z.object({ +export const NoteEditorSchema = z.object({ id: z.string().optional(), title: z.string().min(titleMinLength).max(titleMaxLength), content: z.string().min(contentMinLength).max(contentMaxLength), images: z.array(ImageFieldsetSchema).max(5).optional(), }) -export async function action({ request }: ActionFunctionArgs) { - const userId = await requireUserId(request) - - const formData = await parseMultipartFormData( - request, - createMemoryUploadHandler({ maxPartSize: MAX_UPLOAD_SIZE }), - ) - - const submission = await parseWithZod(formData, { - schema: NoteEditorSchema.superRefine(async (data, ctx) => { - if (!data.id) return - - const note = await prisma.note.findUnique({ - select: { id: true }, - where: { id: data.id, ownerId: userId }, - }) - if (!note) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Note not found', - }) - } - }).transform(async ({ images = [], ...data }) => { - return { - ...data, - imageUpdates: await Promise.all( - images.filter(imageHasId).map(async i => { - if (imageHasFile(i)) { - return { - id: i.id, - altText: i.altText, - contentType: i.file.type, - blob: Buffer.from(await i.file.arrayBuffer()), - } - } else { - return { - id: i.id, - altText: i.altText, - } - } - }), - ), - newImages: await Promise.all( - images - .filter(imageHasFile) - .filter(i => !i.id) - .map(async image => { - return { - altText: image.altText, - contentType: image.file.type, - blob: Buffer.from(await image.file.arrayBuffer()), - } - }), - ), - } - }), - async: true, - }) - - if (submission.status !== 'success') { - return json( - { result: submission.reply() }, - { status: submission.status === 'error' ? 400 : 200 }, - ) - } - - const { - id: noteId, - title, - content, - imageUpdates = [], - newImages = [], - } = submission.value - - const updatedNote = await prisma.note.upsert({ - select: { id: true, owner: { select: { username: true } } }, - where: { id: noteId ?? '__new_note__' }, - create: { - ownerId: userId, - title, - content, - images: { create: newImages }, - }, - update: { - title, - content, - images: { - deleteMany: { id: { notIn: imageUpdates.map(i => i.id) } }, - updateMany: imageUpdates.map(updates => ({ - where: { id: updates.id }, - data: { ...updates, id: updates.blob ? cuid() : updates.id }, - })), - create: newImages, - }, - }, - }) - - return redirect( - `/users/${updatedNote.owner.username}/notes/${updatedNote.id}`, - ) -} - export function NoteEditor({ note, }: { diff --git a/app/routes/users+/$username_+/notes.$noteId_.edit.tsx b/app/routes/users+/$username_+/notes.$noteId_.edit.tsx index ef16acc2..0d148176 100644 --- a/app/routes/users+/$username_+/notes.$noteId_.edit.tsx +++ b/app/routes/users+/$username_+/notes.$noteId_.edit.tsx @@ -4,9 +4,9 @@ import { useLoaderData } from '@remix-run/react' import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx' import { requireUserId } from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' -import { NoteEditor, action } from './__note-editor.tsx' +import { NoteEditor } from './__note-editor.tsx' -export { action } +export { action } from './__note-editor.server.tsx' export async function loader({ params, request }: LoaderFunctionArgs) { const userId = await requireUserId(request) diff --git a/app/routes/users+/$username_+/notes.new.tsx b/app/routes/users+/$username_+/notes.new.tsx index 14a097f6..ecc8580d 100644 --- a/app/routes/users+/$username_+/notes.new.tsx +++ b/app/routes/users+/$username_+/notes.new.tsx @@ -1,11 +1,12 @@ import { json, type LoaderFunctionArgs } from '@remix-run/node' import { requireUserId } from '#app/utils/auth.server.ts' -import { NoteEditor, action } from './__note-editor.tsx' +import { NoteEditor } from './__note-editor.tsx' + +export { action } from './__note-editor.server.tsx' export async function loader({ request }: LoaderFunctionArgs) { await requireUserId(request) return json({}) } -export { action } export default NoteEditor diff --git a/app/utils/cache.server.ts b/app/utils/cache.server.ts index d37db2c7..eaee6193 100644 --- a/app/utils/cache.server.ts +++ b/app/utils/cache.server.ts @@ -14,7 +14,7 @@ import { remember } from '@epic-web/remember' import Database from 'better-sqlite3' import { LRUCache } from 'lru-cache' import { z } from 'zod' -import { updatePrimaryCacheValue } from '#app/routes/admin+/cache_.sqlite.tsx' +import { updatePrimaryCacheValue } from '#app/routes/admin+/cache_.sqlite.server.ts' import { getInstanceInfo, getInstanceInfoSync } from './litefs.server.ts' import { cachifiedTimingReporter, type Timings } from './timing.server.ts' diff --git a/app/utils/litefs.server.ts b/app/utils/litefs.server.ts index 805a9841..0565a5b6 100644 --- a/app/utils/litefs.server.ts +++ b/app/utils/litefs.server.ts @@ -1,5 +1,10 @@ // litefs-js should be used server-side only. It imports `fs` which results in Remix // including a big polyfill. So we put the import in a `.server.ts` file to avoid that // polyfill from being included. https://github.com/epicweb-dev/epic-stack/pull/331 -export * from 'litefs-js' -export * from 'litefs-js/remix.js' +export { + getInstanceInfo, + getAllInstances, + getInternalInstanceDomain, + getInstanceInfoSync, +} from 'litefs-js' +export { ensurePrimary, ensureInstance } from 'litefs-js/remix.js' diff --git a/docs/decisions/036-vite.md b/docs/decisions/036-vite.md new file mode 100644 index 00000000..053ee784 --- /dev/null +++ b/docs/decisions/036-vite.md @@ -0,0 +1,32 @@ +# Adopting Vite + +Date: 2026-02-22 + +Status: accepted + +## Context + +[The Remix Team has created a Vite Plugin](https://remix.run/blog/remix-vite-stable) +and it is now stable. It can be used to replace the existing remix compiler. In +Remix v3 the plugin will be the only supported way to build remix applications. + +Using vite also means we get better hot module replacement, a thriving ecosystem +of tools, and shared efforts with other projects using vite. + +If we don't adopt vite, we'll be stuck on Remix v2 forever 🙃 Now that the vite +plugin is stable, adopting vite is really the only way forward. + +That said, we currently have a few route modules that mix server-only utilities +with server/client code. In vite, you cannot have any exported functions which +use server-only code, so those utilities will need to be moved. Luckily, the +vite plugin will fail the build if it finds any issues so if it builds, it +works. Additionally, this will help us make a cleaner separation between server +and server/client code which is a good thing. + +## Decision + +Adopt vite. + +## Consequences + +Everything is better. diff --git a/package-lock.json b/package-lock.json index 0fe38d82..41577b4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,11 +24,11 @@ "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", "@react-email/components": "0.0.11", - "@remix-run/css-bundle": "^2.5.0", - "@remix-run/express": "^2.5.0", - "@remix-run/node": "^2.5.0", - "@remix-run/react": "^2.5.0", - "@remix-run/server-runtime": "^2.5.0", + "@remix-run/css-bundle": "2.7.2", + "@remix-run/express": "2.7.2", + "@remix-run/node": "2.7.2", + "@remix-run/react": "2.7.2", + "@remix-run/server-runtime": "2.7.2", "@sentry/profiling-node": "^1.3.5", "@sentry/remix": "^7.93.0", "address": "^2.0.1", @@ -77,10 +77,10 @@ "devDependencies": { "@faker-js/faker": "^8.3.1", "@playwright/test": "^1.41.0", - "@remix-run/dev": "^2.5.0", - "@remix-run/eslint-config": "^2.5.0", - "@remix-run/serve": "^2.5.0", - "@remix-run/testing": "^2.5.0", + "@remix-run/dev": "2.7.2", + "@remix-run/eslint-config": "2.7.2", + "@remix-run/serve": "2.7.2", + "@remix-run/testing": "2.7.2", "@sly-cli/sly": "^1.8.0", "@testing-library/jest-dom": "^6.2.0", "@testing-library/react": "^14.1.2", @@ -104,7 +104,6 @@ "@vitejs/plugin-react": "^4.2.1", "@vitest/coverage-v8": "^1.2.1", "autoprefixer": "^10.4.17", - "chokidar": "^3.5.3", "enforce-unique": "^1.2.0", "esbuild": "^0.19.11", "eslint": "^8.56.0", @@ -120,7 +119,7 @@ "remix-flat-routes": "^0.6.4", "tsx": "^4.7.0", "typescript": "^5.3.3", - "vite": "^5.0.11", + "vite": "^5.1.4", "vitest": "^1.2.1" }, "engines": { @@ -2988,17 +2987,17 @@ } }, "node_modules/@remix-run/css-bundle": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@remix-run/css-bundle/-/css-bundle-2.5.0.tgz", - "integrity": "sha512-G57IFEFjte94YMBapbzzFrMnqIAAIf3qUNOsD7PC7VOiLW0AHd75yvq0cDc79zRaRthZhhYb3HkNkE8CsndfHA==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@remix-run/css-bundle/-/css-bundle-2.7.2.tgz", + "integrity": "sha512-oRbNdNJFenuZ4TVYsmk916zbLpcAHcE5TlktMQR5KH0ANLsDiZt/jZRotvRjQ/p8YdyVFUKRRVDWbyG4bJsnFQ==", "engines": { "node": ">=18.0.0" } }, "node_modules/@remix-run/dev": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@remix-run/dev/-/dev-2.5.0.tgz", - "integrity": "sha512-Px+kyoP21b0/N//VPQ7VRaDZE+oVjTWp4QB1mBwdoCPl9gS7E6LA40YYfY51y/Lts+FSMQPJOLd3yVb9zjzL1w==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@remix-run/dev/-/dev-2.7.2.tgz", + "integrity": "sha512-ejxzcQY5DFl1CNG5+5+y0UeklOqkRBO43lAPnSC8ITLigjZY6UZsBENlex/Y+xJzjIUtaRBxc7mVaWa62FDQOg==", "dev": true, "dependencies": { "@babel/core": "^7.21.8", @@ -3011,9 +3010,9 @@ "@babel/types": "^7.22.5", "@mdx-js/mdx": "^2.3.0", "@npmcli/package-json": "^4.0.1", - "@remix-run/node": "2.5.0", - "@remix-run/router": "1.14.2", - "@remix-run/server-runtime": "2.5.0", + "@remix-run/node": "2.7.2", + "@remix-run/router": "1.15.1", + "@remix-run/server-runtime": "2.7.2", "@types/mdx": "^2.0.5", "@vanilla-extract/integration": "^6.2.0", "arg": "^5.0.1", @@ -3062,9 +3061,10 @@ "node": ">=18.0.0" }, "peerDependencies": { - "@remix-run/serve": "^2.5.0", + "@remix-run/serve": "^2.7.2", "typescript": "^5.1.0", - "vite": "^5.0.0" + "vite": "^5.1.0", + "wrangler": "^3.28.2" }, "peerDependenciesMeta": { "@remix-run/serve": { @@ -3075,6 +3075,9 @@ }, "vite": { "optional": true + }, + "wrangler": { + "optional": true } } }, @@ -3665,9 +3668,9 @@ } }, "node_modules/@remix-run/eslint-config": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@remix-run/eslint-config/-/eslint-config-2.5.0.tgz", - "integrity": "sha512-vTWMWE/7zWLWiPQianWXKrhmyG1kTXfwx9dkBVlj9Xg4WIHe/IcuxrRMgNmjwRpTjNv/sVO9pu83oeYm3v9NRg==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@remix-run/eslint-config/-/eslint-config-2.7.2.tgz", + "integrity": "sha512-fAXNlRQQ4DJ0g6GCkUM3sWji18+4i/+FtMA1roSfB9s/+8NEpdZLYJbLo/NGlZUHDC7dXAmTZ3kUZcCCBdOAKw==", "dev": true, "dependencies": { "@babel/core": "^7.21.8", @@ -3702,11 +3705,11 @@ } }, "node_modules/@remix-run/express": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@remix-run/express/-/express-2.5.0.tgz", - "integrity": "sha512-cIwy6Gi2T9nmsov8K/DL4bR9FkMVzxhxkwlcth6T9GfUn+VTQh7eux6w30/RwWXUcpb8YTWbfM0W+GKyOHQgvw==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@remix-run/express/-/express-2.7.2.tgz", + "integrity": "sha512-wknBXniMr7sUvu0D+iOkxK1IenSM0nfZJai1RgNP0Qs12jrKQA5I1dZuiJdKFfLnhvJLbl/BJ6q40TJ4kqNTPg==", "dependencies": { - "@remix-run/node": "2.5.0" + "@remix-run/node": "2.7.2" }, "engines": { "node": ">=18.0.0" @@ -3722,11 +3725,11 @@ } }, "node_modules/@remix-run/node": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@remix-run/node/-/node-2.5.0.tgz", - "integrity": "sha512-TTW4U+GnreqSf08Muz9jOJ5h5jPAPZ+UnwjLrq2O22dNyXrEzz2zecOddQ0H9Uk4ALS0HIu5206nK0pGW0Vdsg==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@remix-run/node/-/node-2.7.2.tgz", + "integrity": "sha512-smIWI9kNGDnY361WlDOTwxPfUw/KHcS7PCLjHlM/omx3ETD1Y2dc4Rt82LSJt0qsPSPA72ZOww08NoX+YGBvyQ==", "dependencies": { - "@remix-run/server-runtime": "2.5.0", + "@remix-run/server-runtime": "2.7.2", "@remix-run/web-fetch": "^4.4.2", "@remix-run/web-file": "^3.1.0", "@remix-run/web-stream": "^1.1.0", @@ -3748,14 +3751,14 @@ } }, "node_modules/@remix-run/react": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@remix-run/react/-/react-2.5.0.tgz", - "integrity": "sha512-CHClKLyUmTAUzDVIOFifRYJ4Lw/LMUQgtFq1grjRDyYRWXIAwxhoZx6BTzcseFUbOwbHGDZ37QCh2e7LFNtRHQ==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@remix-run/react/-/react-2.7.2.tgz", + "integrity": "sha512-Q/gsxJVEYYvkYM/immVFP4rBnGSzEFNJLRt6plO5knAy6NV1NNptsEnvh4GoTVQdTKdVw7wkJW26/YpnHzHiDA==", "dependencies": { - "@remix-run/router": "1.14.2", - "@remix-run/server-runtime": "2.5.0", - "react-router": "6.21.2", - "react-router-dom": "6.21.2" + "@remix-run/router": "1.15.1", + "@remix-run/server-runtime": "2.7.2", + "react-router": "6.22.1", + "react-router-dom": "6.22.1" }, "engines": { "node": ">=18.0.0" @@ -3772,21 +3775,21 @@ } }, "node_modules/@remix-run/router": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.14.2.tgz", - "integrity": "sha512-ACXpdMM9hmKZww21yEqWwiLws/UPLhNKvimN8RrYSqPSvB3ov7sLvAcfvaxePeLvccTQKGdkDIhLYApZVDFuKg==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.1.tgz", + "integrity": "sha512-zcU0gM3z+3iqj8UX45AmWY810l3oUmXM7uH4dt5xtzvMhRtYVhKGOmgOd1877dOPPepfCjUv57w+syamWIYe7w==", "engines": { "node": ">=14.0.0" } }, "node_modules/@remix-run/serve": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@remix-run/serve/-/serve-2.5.0.tgz", - "integrity": "sha512-wrcYJQV8Jbx/8GM62GCLGWghuSZFQwL0RvkZOI2+zVV1lqGXYYJYWAR7JrjLF5GpMsYdfCFi3H9+Go9lpw3+cQ==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@remix-run/serve/-/serve-2.7.2.tgz", + "integrity": "sha512-5kR6fuD2MEWqd8DogyHP3fiSjGv56ZBWKntwMeEKh7kuyDhHPX+BAU2UsYvLOTB1VH9Ktg5mNM8l9gMMY6l/Jg==", "dev": true, "dependencies": { - "@remix-run/express": "2.5.0", - "@remix-run/node": "2.5.0", + "@remix-run/express": "2.7.2", + "@remix-run/node": "2.7.2", "chokidar": "^3.5.3", "compression": "^1.7.4", "express": "^4.17.1", @@ -3814,11 +3817,11 @@ } }, "node_modules/@remix-run/server-runtime": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-2.5.0.tgz", - "integrity": "sha512-Lf/cflOOmx+IAZ1Kd3vKTFhXOS5cUbc2E8pjBE+5xF/1aHI+3NhqqS/haimZ+LaPa4GP8D+0lE6t2Q+2bXJXmg==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-2.7.2.tgz", + "integrity": "sha512-PGD1SPJttlYI3Nwe8ABk53gHCueWfZ2NOsKx7utD/lKRPFW2BqADpokzQXSzy4WTpk4drAmlagobGVIVH66Tmg==", "dependencies": { - "@remix-run/router": "1.14.2", + "@remix-run/router": "1.15.1", "@types/cookie": "^0.6.0", "@web3-storage/multipart-parser": "^1.0.0", "cookie": "^0.6.0", @@ -3838,15 +3841,15 @@ } }, "node_modules/@remix-run/testing": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@remix-run/testing/-/testing-2.5.0.tgz", - "integrity": "sha512-2AcraRUsTtyGT19l54JPau2E2iwpOAgTYrTLSOPdFm42WQY9ghhM7JitMt4xqbWtKzmR2BPIcLyd7xaU9QrAew==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@remix-run/testing/-/testing-2.7.2.tgz", + "integrity": "sha512-pF1gB/vyMlFiLdN15gjsD+8F7seVIhSDfwTqHypcI98s2Q7hUxm3+NG8UnmGRfmnNrsOq7EH63YP8zSC0JS0pg==", "dev": true, "dependencies": { - "@remix-run/node": "2.5.0", - "@remix-run/react": "2.5.0", - "@remix-run/router": "1.14.2", - "react-router-dom": "6.21.2" + "@remix-run/node": "2.7.2", + "@remix-run/react": "2.7.2", + "@remix-run/router": "1.15.1", + "react-router-dom": "6.22.1" }, "engines": { "node": ">=18.0.0" @@ -14765,9 +14768,9 @@ } }, "node_modules/postcss": { - "version": "8.4.33", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz", - "integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==", + "version": "8.4.35", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", + "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", "funding": [ { "type": "opencollective", @@ -15729,11 +15732,11 @@ } }, "node_modules/react-router": { - "version": "6.21.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.21.2.tgz", - "integrity": "sha512-jJcgiwDsnaHIeC+IN7atO0XiSRCrOsQAHHbChtJxmgqG2IaYQXSnhqGb5vk2CU/wBQA12Zt+TkbuJjIn65gzbA==", + "version": "6.22.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.1.tgz", + "integrity": "sha512-0pdoRGwLtemnJqn1K0XHUbnKiX0S4X8CgvVVmHGOWmofESj31msHo/1YiqcJWK7Wxfq2a4uvvtS01KAQyWK/CQ==", "dependencies": { - "@remix-run/router": "1.14.2" + "@remix-run/router": "1.15.1" }, "engines": { "node": ">=14.0.0" @@ -15743,12 +15746,12 @@ } }, "node_modules/react-router-dom": { - "version": "6.21.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.21.2.tgz", - "integrity": "sha512-tE13UukgUOh2/sqYr6jPzZTzmzc70aGRP4pAjG2if0IP3aUT+sBtAKUJh0qMh0zylJHGLmzS+XWVaON4UklHeg==", + "version": "6.22.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.1.tgz", + "integrity": "sha512-iwMyyyrbL7zkKY7MRjOVRy+TMnS/OPusaFVxM2P11x9dzSzGmLsebkCvYirGq0DWB9K9hOspHYYtDz33gE5Duw==", "dependencies": { - "@remix-run/router": "1.14.2", - "react-router": "6.21.2" + "@remix-run/router": "1.15.1", + "react-router": "6.22.1" }, "engines": { "node": ">=14.0.0" @@ -18494,13 +18497,13 @@ } }, "node_modules/vite": { - "version": "5.0.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.11.tgz", - "integrity": "sha512-XBMnDjZcNAw/G1gEiskiM1v6yzM4GE5aMGvhWTlHAYYhxb7S3/V1s3m2LDHa8Vh6yIWYYB0iJwsEaS523c4oYA==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.4.tgz", + "integrity": "sha512-n+MPqzq+d9nMVTKyewqw6kSt+R3CkvF9QAKY8obiQn8g1fwTscKxyfaYnC632HtBXAQGc1Yjomphwn1dtwGAHg==", "dev": true, "dependencies": { "esbuild": "^0.19.3", - "postcss": "^8.4.32", + "postcss": "^8.4.35", "rollup": "^4.2.0" }, "bin": { diff --git a/package.json b/package.json index 00660168..d9883490 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "scripts": { "build": "run-s build:*", "build:icons": "tsx ./other/build-icons.ts", - "build:remix": "remix build --sourcemap", + "build:remix": "remix vite:build --sourcemapClient", "build:server": "tsx ./other/build-server.ts", "predev": "npm run build:icons --silent", - "dev": "remix dev -c \"node ./server/dev-server.js\" --manual", + "dev": "node ./server/dev-server.js", "prisma:studio": "prisma studio", "format": "prettier --write .", "lint": "eslint .", @@ -57,11 +57,11 @@ "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", "@react-email/components": "0.0.11", - "@remix-run/css-bundle": "^2.5.0", - "@remix-run/express": "^2.5.0", - "@remix-run/node": "^2.5.0", - "@remix-run/react": "^2.5.0", - "@remix-run/server-runtime": "^2.5.0", + "@remix-run/css-bundle": "2.7.2", + "@remix-run/express": "2.7.2", + "@remix-run/node": "2.7.2", + "@remix-run/react": "2.7.2", + "@remix-run/server-runtime": "2.7.2", "@sentry/profiling-node": "^1.3.5", "@sentry/remix": "^7.93.0", "address": "^2.0.1", @@ -110,10 +110,10 @@ "devDependencies": { "@faker-js/faker": "^8.3.1", "@playwright/test": "^1.41.0", - "@remix-run/dev": "^2.5.0", - "@remix-run/eslint-config": "^2.5.0", - "@remix-run/serve": "^2.5.0", - "@remix-run/testing": "^2.5.0", + "@remix-run/dev": "2.7.2", + "@remix-run/eslint-config": "2.7.2", + "@remix-run/serve": "2.7.2", + "@remix-run/testing": "2.7.2", "@sly-cli/sly": "^1.8.0", "@testing-library/jest-dom": "^6.2.0", "@testing-library/react": "^14.1.2", @@ -137,7 +137,6 @@ "@vitejs/plugin-react": "^4.2.1", "@vitest/coverage-v8": "^1.2.1", "autoprefixer": "^10.4.17", - "chokidar": "^3.5.3", "enforce-unique": "^1.2.0", "esbuild": "^0.19.11", "eslint": "^8.56.0", @@ -153,7 +152,7 @@ "remix-flat-routes": "^0.6.4", "tsx": "^4.7.0", "typescript": "^5.3.3", - "vite": "^5.0.11", + "vite": "^5.1.4", "vitest": "^1.2.1" }, "engines": { diff --git a/remix.config.js b/remix.config.js deleted file mode 100644 index 58cfcad7..00000000 --- a/remix.config.js +++ /dev/null @@ -1,24 +0,0 @@ -import { flatRoutes } from 'remix-flat-routes' - -/** - * @type {import('@remix-run/dev').AppConfig} - */ -export default { - cacheDirectory: './node_modules/.cache/remix', - ignoredRouteFiles: ['**/*'], - serverModuleFormat: 'esm', - serverPlatform: 'node', - tailwind: true, - postcss: true, - watchPaths: ['./tailwind.config.ts'], - routes: async defineRoutes => { - return flatRoutes('routes', defineRoutes, { - ignoredRouteFiles: [ - '.*', - '**/*.css', - '**/*.test.{js,jsx,ts,tsx}', - '**/__*.*', - ], - }) - }, -} diff --git a/server/dev-server.js b/server/dev-server.js index fb614568..2b8bdae3 100644 --- a/server/dev-server.js +++ b/server/dev-server.js @@ -4,7 +4,7 @@ if (process.env.NODE_ENV === 'production') { await import('./index.js') } else { const command = - 'tsx watch --clear-screen=false --ignore "app/**" --ignore "build/**" --ignore "node_modules/**" --inspect ./index.js' + 'tsx watch --clear-screen=false --ignore ".cache/**" --ignore "app/**" --ignore "vite.config.ts.timestamp-*" --ignore "build/**" --ignore "node_modules/**" --inspect ./index.js' execa(command, { stdio: ['ignore', 'inherit', 'inherit'], shell: true, diff --git a/server/index.ts b/server/index.ts index c89520b9..07f4890e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,15 +1,6 @@ import crypto from 'crypto' -import path from 'path' -import { fileURLToPath } from 'url' -import { - createRequestHandler as _createRequestHandler, - type RequestHandler, -} from '@remix-run/express' -import { - broadcastDevReady, - installGlobals, - type ServerBuild, -} from '@remix-run/node' +import { createRequestHandler as _createRequestHandler } from '@remix-run/express' +import { type ServerBuild, installGlobals } from '@remix-run/node' import * as Sentry from '@sentry/remix' import { ip as ipAddress } from 'address' import chalk from 'chalk' @@ -25,19 +16,19 @@ installGlobals() const MODE = process.env.NODE_ENV -const createRequestHandler = Sentry.wrapExpressCreateRequestHandler( - _createRequestHandler, -) - -const BUILD_PATH = '../build/index.js' -const WATCH_PATH = '../build/version.txt' +const createRequestHandler = + MODE === 'production' + ? Sentry.wrapExpressCreateRequestHandler(_createRequestHandler) + : _createRequestHandler -/** - * Initial build - * @type {ServerBuild} - */ -const build = await import(BUILD_PATH) -let devBuild = build +const viteDevServer = + MODE === 'production' + ? undefined + : await import('vite').then(vite => + vite.createServer({ + server: { middlewareMode: true }, + }), + ) const app = express() @@ -79,23 +70,27 @@ app.disable('x-powered-by') app.use(Sentry.Handlers.requestHandler()) app.use(Sentry.Handlers.tracingHandler()) -// Remix fingerprints its assets so we can cache forever. -app.use( - '/build', - express.static('public/build', { immutable: true, maxAge: '1y' }), -) +if (viteDevServer) { + app.use(viteDevServer.middlewares) +} else { + // Remix fingerprints its assets so we can cache forever. + app.use( + '/assets', + express.static('build/client/assets', { immutable: true, maxAge: '1y' }), + ) -// Everything else (like favicon.ico) is cached for an hour. You may want to be -// more aggressive with this caching. -app.use(express.static('public', { maxAge: '1h' })) + // Everything else (like favicon.ico) is cached for an hour. You may want to be + // more aggressive with this caching. + app.use(express.static('build/client', { maxAge: '1h' })) +} -app.get(['/build/*', '/img/*', '/fonts/*', '/favicons/*'], (req, res) => { +app.get(['/img/*', '/favicons/*'], (req, res) => { // if we made it past the express.static for these, then we're missing something. // So we'll just send a 404 and won't bother calling other middleware. return res.status(404).send('Not found') }) -morgan.token('url', (req, res) => decodeURIComponent(req.url ?? '')) +morgan.token('url', req => decodeURIComponent(req.url ?? '')) app.use( morgan('tiny', { skip: (req, res) => @@ -199,18 +194,27 @@ app.use((req, res, next) => { return generalRateLimit(req, res, next) }) -function getRequestHandler(build: ServerBuild): RequestHandler { - function getLoadContext(_: any, res: any) { - return { cspNonce: res.locals.cspNonce } - } - return createRequestHandler({ build, mode: MODE, getLoadContext }) +async function getBuild() { + const build = viteDevServer + ? viteDevServer.ssrLoadModule('virtual:remix/server-build') + : // @ts-ignore this should exist before running the server + // but it may not exist just yet. + await import('#build/server/index.js') + // not sure how to make this happy 🤷‍♂️ + return build as unknown as ServerBuild } app.all( '*', - MODE === 'development' - ? (...args) => getRequestHandler(devBuild)(...args) - : getRequestHandler(build), + createRequestHandler({ + getLoadContext: (_: any, res: any) => ({ + cspNonce: res.locals.cspNonce, + serverBuild: getBuild(), + }), + mode: MODE, + // @sentry/remix needs to be updated to handle the function signature + build: MODE === 'production' ? await getBuild() : getBuild, + }), ) const desiredPort = Number(process.env.PORT || 3000) @@ -252,10 +256,6 @@ ${lanUrl ? `${chalk.bold('On Your Network:')} ${chalk.cyan(lanUrl)}` : ''} ${chalk.bold('Press Ctrl+C to stop')} `.trim(), ) - - if (MODE === 'development') { - broadcastDevReady(build) - } }) closeWithGrace(async () => { @@ -263,24 +263,3 @@ closeWithGrace(async () => { server.close(e => (e ? reject(e) : resolve('ok'))) }) }) - -// during dev, we'll keep the build module up to date with the changes -if (MODE === 'development') { - async function reloadBuild() { - devBuild = await import(`${BUILD_PATH}?update=${Date.now()}`) - broadcastDevReady(devBuild) - } - - // We'll import chokidar here so doesn't get bundled in production. - const chokidar = await import('chokidar') - - const dirname = path.dirname(fileURLToPath(import.meta.url)) - const watchPath = path.join(dirname, WATCH_PATH).replace(/\\/g, '/') - - const buildWatcher = chokidar - .watch(watchPath, { ignoreInitial: true }) - .on('add', reloadBuild) - .on('change', reloadBuild) - - closeWithGrace(() => buildWatcher.close()) -} diff --git a/tsconfig.json b/tsconfig.json index 059910a8..b92f5668 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "isolatedModules": true, "esModuleInterop": true, "jsx": "react-jsx", - "module": "ES2022", + "module": "ESNext", "target": "ES2022", "moduleResolution": "bundler", "resolveJsonModule": true, diff --git a/types/env.env.d.ts b/types/env.env.d.ts new file mode 100644 index 00000000..8d2f9518 --- /dev/null +++ b/types/env.env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/types/remix.env.d.ts b/types/remix.env.d.ts deleted file mode 100644 index 72e2affe..00000000 --- a/types/remix.env.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -/// -/// diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 00000000..b4e95c36 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,36 @@ +import { vitePlugin as remix } from '@remix-run/dev' +import { flatRoutes } from 'remix-flat-routes' +import { defineConfig } from 'vite' + +const MODE = process.env.NODE_ENV + +export default defineConfig({ + build: { + cssMinify: MODE === 'production', + rollupOptions: { + external: [/node:.*/, 'stream', 'crypto', 'fsevents'], + }, + }, + plugins: [ + remix({ + ignoredRouteFiles: ['**/*'], + serverModuleFormat: 'esm', + routes: async defineRoutes => { + return flatRoutes('routes', defineRoutes, { + ignoredRouteFiles: [ + '.*', + '**/*.css', + '**/*.test.{js,jsx,ts,tsx}', + '**/__*.*', + // This is for server-side utilities you want to colocate next to + // your routes without making an additional directory. + // If you need a route that includes "server" or "client" in the + // filename, use the escape brackets like: my-route.[server].tsx + '**/*.server.*', + '**/*.client.*', + ], + }) + }, + }), + ], +})