From 4e7a06811da09ed85d5f023ccd3c5c2191f1e4b0 Mon Sep 17 00:00:00 2001 From: Thad Kerosky Date: Tue, 5 Dec 2023 20:44:39 -0500 Subject: [PATCH 1/2] Incorporate new Epic-Stack template updates like Remix v2.3.1 (#117) * epic v2.3: NEW .env.example; csrf security * epic v2.3: NEW .env.example; csrf security * Comment about potentially unneccesary & readme Co-authored-by: plocket Co-authored-by: Gavin Kimball Co-authored-by: Clayton Schneider Co-authored-by: Jeff Korenstein Co-authored-by: Tai Co-authored-by: Tai Phuong --------- Co-authored-by: plocket Co-authored-by: Gavin Kimball Co-authored-by: Clayton Schneider Co-authored-by: Jeff Korenstein Co-authored-by: Tai --- .github/workflows/heat-stack.yml | 8 +- .gitignore | 5 + .vscode/extensions.json | 11 + heat-stack/.env.example | 1 + heat-stack/README.md | 4 + heat-stack/app/components/confetti.tsx | 21 - heat-stack/app/components/error-boundary.tsx | 4 +- heat-stack/app/components/forms.tsx | 4 +- heat-stack/app/components/progress-bar.tsx | 63 + heat-stack/app/components/search-bar.tsx | 6 +- heat-stack/app/components/spinner.tsx | 1 + heat-stack/app/components/ui/button.tsx | 6 +- heat-stack/app/entry.server.tsx | 14 + heat-stack/app/root.tsx | 30 +- heat-stack/app/root_original.tsx | 12 +- heat-stack/app/root_original_v2.3.tsx | 451 + .../_auth+/auth.$provider.callback.test.ts | 10 +- .../app/routes/_auth+/forgot-password.tsx | 10 +- heat-stack/app/routes/_auth+/login.tsx | 51 +- heat-stack/app/routes/_auth+/onboarding.tsx | 77 +- .../routes/_auth+/onboarding_.$provider.tsx | 24 +- .../app/routes/_auth+/reset-password.tsx | 20 +- heat-stack/app/routes/_auth+/signup.tsx | 29 +- heat-stack/app/routes/_auth+/verify.tsx | 88 +- .../app/routes/_marketing+/logos/docker.png | Bin 90758 -> 0 bytes .../app/routes/_marketing+/logos/docker.svg | 47 + .../app/routes/_marketing+/logos/logos.ts | 4 +- .../app/routes/_marketing+/logos/remix.png | Bin 204228 -> 0 bytes .../app/routes/_marketing+/logos/remix.svg | 25 + heat-stack/app/routes/_seo+/robots[.]txt.ts | 9 + heat-stack/app/routes/_seo+/sitemap[.]xml.ts | 14 + heat-stack/app/routes/admin+/cache.tsx | 7 +- heat-stack/app/routes/me.tsx | 4 +- .../app/routes/resources+/healthcheck.tsx | 27 +- .../routes/settings+/profile.change-email.tsx | 11 +- .../routes/settings+/profile.connections.tsx | 109 +- .../app/routes/settings+/profile.index.tsx | 30 +- .../app/routes/settings+/profile.password.tsx | 13 +- .../settings+/profile.password_.create.tsx | 35 +- .../app/routes/settings+/profile.photo.tsx | 172 +- heat-stack/app/routes/settings+/profile.tsx | 55 +- .../settings+/profile.two-factor.disable.tsx | 11 +- .../settings+/profile.two-factor.index.tsx | 13 +- .../routes/settings+/profile.two-factor.tsx | 5 +- .../settings+/profile.two-factor.verify.tsx | 86 +- .../app/routes/users+/$username.test.tsx | 10 +- .../users+/$username_+/__note-editor.tsx | 26 +- .../users+/$username_+/notes.$noteId.tsx | 12 +- .../routes/users+/$username_+/notes.new.tsx | 2 +- heat-stack/app/styles/tailwind.css | 20 +- heat-stack/app/utils/auth.server.ts | 21 +- heat-stack/app/utils/cache.server.ts | 6 +- heat-stack/app/utils/client-hints.tsx | 122 +- heat-stack/app/utils/confetti.server.ts | 42 - heat-stack/app/utils/connections.server.ts | 4 +- heat-stack/app/utils/csrf.server.ts | 23 + heat-stack/app/utils/env.server.ts | 3 +- heat-stack/app/utils/extended-theme.ts | 4 +- heat-stack/app/utils/honeypot.server.ts | 17 + heat-stack/app/utils/misc.tsx | 34 +- heat-stack/app/utils/monitoring.client.tsx | 2 + heat-stack/app/utils/monitoring.server.ts | 38 +- .../app/utils/providers/github.server.ts | 43 +- heat-stack/app/utils/providers/provider.ts | 6 +- heat-stack/app/utils/session.server.ts | 6 +- heat-stack/app/utils/timing.server.ts | 2 +- heat-stack/app/utils/totp.server.ts | 3 + heat-stack/app/utils/user-validation.ts | 12 + heat-stack/index.js | 17 +- heat-stack/other/Dockerfile | 2 +- .../other/patches/@remix-run+dev+1.19.1.patch | 27 + .../other/patches/remix-utils+6.6.0.patch | 19 - heat-stack/other/sentry-create-release.js | 7 + heat-stack/package-lock.json | 7651 ++++++++++------- heat-stack/package.json | 146 +- .../20230914194400_init/migration.sql | 41 + heat-stack/prisma/seed.ts | 14 +- heat-stack/server/index.ts | 27 +- heat-stack/tests/db-utils.ts | 17 + heat-stack/tests/e2e/2fa.test.ts | 2 +- heat-stack/tests/e2e/error-boundary.test.ts | 5 +- heat-stack/tests/e2e/note-images.test.ts | 140 + heat-stack/tests/e2e/onboarding.test.ts | 18 +- heat-stack/tests/mocks/index.ts | 4 +- heat-stack/tests/playwright-utils.ts | 10 +- heat-stack/tests/setup/custom-matchers.ts | 6 +- heat-stack/tests/setup/db-setup.ts | 10 +- heat-stack/tests/setup/global-setup.ts | 16 +- heat-stack/tests/utils.ts | 8 +- heat-stack/vitest.config.ts | 1 - 90 files changed, 6270 insertions(+), 4003 deletions(-) create mode 100644 .vscode/extensions.json delete mode 100644 heat-stack/app/components/confetti.tsx create mode 100644 heat-stack/app/components/progress-bar.tsx create mode 100644 heat-stack/app/root_original_v2.3.tsx delete mode 100644 heat-stack/app/routes/_marketing+/logos/docker.png create mode 100644 heat-stack/app/routes/_marketing+/logos/docker.svg delete mode 100644 heat-stack/app/routes/_marketing+/logos/remix.png create mode 100644 heat-stack/app/routes/_marketing+/logos/remix.svg create mode 100644 heat-stack/app/routes/_seo+/robots[.]txt.ts create mode 100644 heat-stack/app/routes/_seo+/sitemap[.]xml.ts delete mode 100644 heat-stack/app/utils/confetti.server.ts create mode 100644 heat-stack/app/utils/csrf.server.ts create mode 100644 heat-stack/app/utils/honeypot.server.ts create mode 100644 heat-stack/app/utils/totp.server.ts create mode 100644 heat-stack/other/patches/@remix-run+dev+1.19.1.patch delete mode 100644 heat-stack/other/patches/remix-utils+6.6.0.patch create mode 100644 heat-stack/tests/e2e/note-images.test.ts diff --git a/.github/workflows/heat-stack.yml b/.github/workflows/heat-stack.yml index 140bfd2e..648b2112 100644 --- a/.github/workflows/heat-stack.yml +++ b/.github/workflows/heat-stack.yml @@ -24,7 +24,7 @@ permissions: jobs: lint: name: ⬣ ESLint - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: ⬇️ Checkout repo uses: actions/checkout@v3 @@ -50,7 +50,7 @@ jobs: typecheck: name: ʦ TypeScript - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: ⬇️ Checkout repo uses: actions/checkout@v3 @@ -73,7 +73,7 @@ jobs: vitest: name: ⚡ Vitest pyodide.test.ts - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: ⬇️ Checkout repo uses: actions/checkout@v3 @@ -100,7 +100,7 @@ jobs: # playwright tests work great but slight jank/inconsistency passing, and not used yet, so disabling for now # playwright: # name: 🎭 Playwright - # runs-on: ubuntu-latest + # runs-on: ubuntu-22.04 # timeout-minutes: 60 # steps: # - name: ⬇️ Checkout repo diff --git a/.gitignore b/.gitignore index 5e16b5c7..ee899313 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,8 @@ node_modules # Easy way to create temporary files/folders that won't accidentally be added to git *.local.* + +#local temporary folders +heat-app +venv +heat-tmp \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..7619ac2b --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,11 @@ +{ + "recommendations": [ + "bradlc.vscode-tailwindcss", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "prisma.prisma", + "qwtel.sqlite-viewer", + "yoavbls.pretty-ts-errors", + "github.vscode-github-actions" + ] +} diff --git a/heat-stack/.env.example b/heat-stack/.env.example index d117323c..46659864 100644 --- a/heat-stack/.env.example +++ b/heat-stack/.env.example @@ -3,6 +3,7 @@ DATABASE_PATH="./prisma/data.db" DATABASE_URL="file:./data.db?connection_limit=1" CACHE_DATABASE_PATH="./other/cache.db" SESSION_SECRET="super-duper-s3cret" +HONEYPOT_SECRET="super-duper-s3cret" INTERNAL_COMMAND_TOKEN="some-made-up-token" RESEND_API_KEY="re_blAh_blaHBlaHblahBLAhBlAh" SENTRY_DSN="your-dsn" diff --git a/heat-stack/README.md b/heat-stack/README.md index a63d17af..60a8605b 100644 --- a/heat-stack/README.md +++ b/heat-stack/README.md @@ -17,6 +17,7 @@ npm install npm run dev ``` + ### Set up in a new GitHub CodingSpace: ``` @@ -30,6 +31,9 @@ npm run dev If you have the node version manager (`nvm`), then `nvm use 18` avoids engine error with node v19+ or newer which is default. nvm is preinstalled in coding spaces. + +In Coding Spaces VSCode always go to hamburger menu -> File-> untick AutoSave. For a pic, see https://stackoverflow.com/a/76659316/14144258 + ### Under special circumstances: Assume you don't need to, but if the version of pyodide changes run: diff --git a/heat-stack/app/components/confetti.tsx b/heat-stack/app/components/confetti.tsx deleted file mode 100644 index 69fbecef..00000000 --- a/heat-stack/app/components/confetti.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Index as ConfettiShower } from 'confetti-react' -import { ClientOnly } from 'remix-utils' - -export function Confetti({ id }: { id?: string | null }) { - if (!id) return null - - return ( - - {() => ( - - )} - - ) -} diff --git a/heat-stack/app/components/error-boundary.tsx b/heat-stack/app/components/error-boundary.tsx index 12175f31..80c1406b 100644 --- a/heat-stack/app/components/error-boundary.tsx +++ b/heat-stack/app/components/error-boundary.tsx @@ -1,9 +1,10 @@ import { + type ErrorResponse, isRouteErrorResponse, useParams, useRouteError, } from '@remix-run/react' -import { type ErrorResponse } from '@remix-run/router' +import { captureRemixErrorBoundaryError } from '@sentry/remix' import { getErrorMessage } from '#app/utils/misc.tsx' type StatusHandler = (info: { @@ -25,6 +26,7 @@ export function GeneralErrorBoundary({ unexpectedErrorHandler?: (error: unknown) => JSX.Element | null }) { const error = useRouteError() + captureRemixErrorBoundaryError(error) const params = useParams() if (typeof document !== 'undefined') { diff --git a/heat-stack/app/components/forms.tsx b/heat-stack/app/components/forms.tsx index 0d362fcb..ad2868ad 100644 --- a/heat-stack/app/components/forms.tsx +++ b/heat-stack/app/components/forms.tsx @@ -19,7 +19,7 @@ export function ErrorList({ return (
    {errorsToRender.map(e => ( -
  • +
  • {e}
  • ))} @@ -64,7 +64,7 @@ export function TextareaField({ className, }: { labelProps: React.LabelHTMLAttributes - textareaProps: React.InputHTMLAttributes + textareaProps: React.TextareaHTMLAttributes errors?: ListOfErrors className?: string }) { diff --git a/heat-stack/app/components/progress-bar.tsx b/heat-stack/app/components/progress-bar.tsx new file mode 100644 index 00000000..f7f9b495 --- /dev/null +++ b/heat-stack/app/components/progress-bar.tsx @@ -0,0 +1,63 @@ +import { useNavigation } from '@remix-run/react' +import { useEffect, useRef, useState } from 'react' +import { useSpinDelay } from 'spin-delay' +import { cn } from '#app/utils/misc.tsx' +import { Icon } from './ui/icon.tsx' + +function EpicProgress() { + const transition = useNavigation() + const busy = transition.state !== 'idle' + const delayedPending = useSpinDelay(busy, { + delay: 600, + minDuration: 400, + }) + const ref = useRef(null) + const [animationComplete, setAnimationComplete] = useState(true) + + useEffect(() => { + if (!ref.current) return + if (delayedPending) setAnimationComplete(false) + + const animationPromises = ref.current + .getAnimations() + .map(({ finished }) => finished) + + Promise.allSettled(animationPromises).then(() => { + if (!delayedPending) setAnimationComplete(true) + }) + }, [delayedPending]) + + return ( +
    +
    + {delayedPending && ( +
    + +
    + )} +
    + ) +} + +export { EpicProgress } diff --git a/heat-stack/app/components/search-bar.tsx b/heat-stack/app/components/search-bar.tsx index 859c52d6..84681927 100644 --- a/heat-stack/app/components/search-bar.tsx +++ b/heat-stack/app/components/search-bar.tsx @@ -1,4 +1,5 @@ import { Form, useSearchParams, useSubmit } from '@remix-run/react' +import { useId } from 'react' import { useDebounce, useIsPending } from '#app/utils/misc.tsx' import { Icon } from './ui/icon.tsx' import { Input } from './ui/input.tsx' @@ -14,6 +15,7 @@ export function SearchBar({ autoFocus?: boolean autoSubmit?: boolean }) { + const id = useId() const [searchParams] = useSearchParams() const submit = useSubmit() const isSubmitting = useIsPending({ @@ -33,13 +35,13 @@ export function SearchBar({ onChange={e => autoSubmit && handleFormChange(e.currentTarget)} >
    -
    - + {/* */} ) diff --git a/heat-stack/app/root_original_v2.3.tsx b/heat-stack/app/root_original_v2.3.tsx new file mode 100644 index 00000000..2be98038 --- /dev/null +++ b/heat-stack/app/root_original_v2.3.tsx @@ -0,0 +1,451 @@ +import { useForm } from '@conform-to/react' +import { parse } from '@conform-to/zod' +import { cssBundleHref } from '@remix-run/css-bundle' +import { + json, + type DataFunctionArgs, + type HeadersFunction, + type LinksFunction, + type MetaFunction, +} from '@remix-run/node' +import { + Form, + Link, + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useFetcher, + useFetchers, + useLoaderData, + useMatches, + useSubmit, +} from '@remix-run/react' +import { withSentry } from '@sentry/remix' +import { useRef } from 'react' +import { AuthenticityTokenProvider } from 'remix-utils/csrf/react' +import { HoneypotProvider } from 'remix-utils/honeypot/react' +import { z } from 'zod' +import { GeneralErrorBoundary } from './components/error-boundary.tsx' +import { ErrorList } from './components/forms.tsx' +import { EpicProgress } from './components/progress-bar.tsx' +import { SearchBar } from './components/search-bar.tsx' +import { EpicToaster } from './components/toaster.tsx' +import { Button } from './components/ui/button.tsx' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuPortal, + DropdownMenuTrigger, +} from './components/ui/dropdown-menu.tsx' +import { Icon, href as iconsHref } from './components/ui/icon.tsx' +import fontStyleSheetUrl from './styles/font.css' +import tailwindStyleSheetUrl from './styles/tailwind.css' +import { getUserId, logout } from './utils/auth.server.ts' +import { ClientHintCheck, getHints, useHints } from './utils/client-hints.tsx' +import { csrf } from './utils/csrf.server.ts' +import { prisma } from './utils/db.server.ts' +import { getEnv } from './utils/env.server.ts' +import { honeypot } from './utils/honeypot.server.ts' +import { combineHeaders, getDomainUrl, getUserImgSrc } from './utils/misc.tsx' +import { useNonce } from './utils/nonce-provider.ts' +import { useRequestInfo } from './utils/request-info.ts' +import { type Theme, setTheme, getTheme } from './utils/theme.server.ts' +import { makeTimings, time } from './utils/timing.server.ts' +import { getToast } from './utils/toast.server.ts' +import { useOptionalUser, useUser } from './utils/user.ts' + +export const links: LinksFunction = () => { + return [ + // Preload svg sprite as a resource to avoid render blocking + { rel: 'preload', href: iconsHref, as: 'image' }, + // Preload CSS as a resource to avoid render blocking + { rel: 'preload', href: fontStyleSheetUrl, as: 'style' }, + { rel: 'preload', href: tailwindStyleSheetUrl, as: 'style' }, + cssBundleHref ? { rel: 'preload', href: cssBundleHref, as: 'style' } : null, + { rel: 'mask-icon', href: '/favicons/mask-icon.svg' }, + { + rel: 'alternate icon', + type: 'image/png', + href: '/favicons/favicon-32x32.png', + }, + { rel: 'apple-touch-icon', href: '/favicons/apple-touch-icon.png' }, + { + rel: 'manifest', + href: '/site.webmanifest', + crossOrigin: 'use-credentials', + } as const, // necessary to make typescript happy + //These should match the css preloads above to avoid css as render blocking resource + { rel: 'icon', type: 'image/svg+xml', href: '/favicons/favicon.svg' }, + { rel: 'stylesheet', href: fontStyleSheetUrl }, + { rel: 'stylesheet', href: tailwindStyleSheetUrl }, + cssBundleHref ? { rel: 'stylesheet', href: cssBundleHref } : null, + ].filter(Boolean) +} + +export const meta: MetaFunction = ({ data }) => { + return [ + { title: data ? 'Epic Notes' : 'Error | Epic Notes' }, + { name: 'description', content: `Your own captain's log` }, + ] +} + +export async function loader({ request }: DataFunctionArgs) { + const timings = makeTimings('root loader') + const userId = await time(() => getUserId(request), { + timings, + type: 'getUserId', + desc: 'getUserId in root', + }) + + const user = userId + ? await time( + () => + prisma.user.findUniqueOrThrow({ + select: { + id: true, + name: true, + username: true, + image: { select: { id: true } }, + roles: { + select: { + name: true, + permissions: { + select: { entity: true, action: true, access: true }, + }, + }, + }, + }, + where: { id: userId }, + }), + { timings, type: 'find user', desc: 'find user in root' }, + ) + : null + if (userId && !user) { + console.info('something weird happened') + // something weird happened... The user is authenticated but we can't find + // them in the database. Maybe they were deleted? Let's log them out. + await logout({ request, redirectTo: '/' }) + } + const { toast, headers: toastHeaders } = await getToast(request) + const honeyProps = honeypot.getInputProps() + const [csrfToken, csrfCookieHeader] = await csrf.commitToken() + + return json( + { + user, + requestInfo: { + hints: getHints(request), + origin: getDomainUrl(request), + path: new URL(request.url).pathname, + userPrefs: { + theme: getTheme(request), + }, + }, + ENV: getEnv(), + toast, + honeyProps, + csrfToken, + }, + { + headers: combineHeaders( + { 'Server-Timing': timings.toString() }, + toastHeaders, + csrfCookieHeader ? { 'set-cookie': csrfCookieHeader } : null, + ), + }, + ) +} + +export const headers: HeadersFunction = ({ loaderHeaders }) => { + const headers = { + 'Server-Timing': loaderHeaders.get('Server-Timing') ?? '', + } + return headers +} + +const ThemeFormSchema = z.object({ + theme: z.enum(['system', 'light', 'dark']), +}) + +export async function action({ request }: DataFunctionArgs) { + const formData = await request.formData() + const submission = parse(formData, { + schema: ThemeFormSchema, + }) + if (submission.intent !== 'submit') { + return json({ status: 'idle', submission } as const) + } + if (!submission.value) { + return json({ status: 'error', submission } as const, { status: 400 }) + } + const { theme } = submission.value + + const responseInit = { + headers: { 'set-cookie': setTheme(theme) }, + } + return json({ success: true, submission }, responseInit) +} + +function Document({ + children, + nonce, + theme = 'light', + env = {}, +}: { + children: React.ReactNode + nonce: string + theme?: Theme + env?: Record +}) { + return ( + + + + + + + + + + {children} +