├── .dockerignore ├── .env.example ├── .eslintrc.cjs ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── deploy.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.cjs ├── Dockerfile ├── README.md ├── app ├── components │ ├── error-boundary.tsx │ ├── spacer.tsx │ └── spinner.tsx ├── entry.client.tsx ├── entry.server.tsx ├── root.tsx ├── routes │ ├── _auth+ │ │ ├── forgot-password.tsx │ │ ├── forgot-password_.verify.tsx │ │ ├── login.tsx │ │ ├── logout.tsx │ │ ├── onboarding.tsx │ │ ├── reset-password.tsx │ │ ├── signup.tsx │ │ └── signup_.verify.tsx │ ├── _marketing+ │ │ ├── about.tsx │ │ ├── index.tsx │ │ ├── logos │ │ │ ├── docker.png │ │ │ ├── eslint.svg │ │ │ ├── faker.svg │ │ │ ├── fly.svg │ │ │ ├── github.svg │ │ │ ├── kody-rocket.png │ │ │ ├── logos.ts │ │ │ ├── mailgun.png │ │ │ ├── msw.svg │ │ │ ├── playwright.svg │ │ │ ├── prettier.svg │ │ │ ├── prisma.svg │ │ │ ├── radix.svg │ │ │ ├── remix.png │ │ │ ├── sentry.svg │ │ │ ├── sqlite.svg │ │ │ ├── stars.jpg │ │ │ ├── tailwind.svg │ │ │ ├── testing-library.png │ │ │ ├── typescript.svg │ │ │ ├── vitest.svg │ │ │ └── zod.svg │ │ ├── privacy.tsx │ │ ├── support.tsx │ │ └── tos.tsx │ ├── admin+ │ │ ├── cache.tsx │ │ ├── cache_.lru.$cacheKey.ts │ │ ├── cache_.sqlite.$cacheKey.ts │ │ └── cache_.sqlite.tsx │ ├── me.tsx │ ├── resources+ │ │ ├── delete-image.test.tsx │ │ ├── delete-image.tsx │ │ ├── delete-note.tsx │ │ ├── file.$fileId.tsx │ │ ├── healthcheck.tsx │ │ ├── image-upload.tsx │ │ ├── login.tsx │ │ ├── note-editor.tsx │ │ ├── theme │ │ │ ├── index.tsx │ │ │ └── theme-session.server.ts │ │ └── verify.tsx │ ├── settings+ │ │ ├── profile.photo.tsx │ │ ├── profile.tsx │ │ ├── profile.two-factor.index.tsx │ │ ├── profile.two-factor.tsx │ │ └── profile.two-factor.verify.tsx │ └── users+ │ │ ├── $username.tsx │ │ └── $username_+ │ │ ├── notes.$noteId.tsx │ │ ├── notes.$noteId_.edit.tsx │ │ ├── notes.index.tsx │ │ ├── notes.new.tsx │ │ └── notes.tsx ├── styles │ ├── font.css │ └── tailwind.css └── utils │ ├── auth.server.ts │ ├── cache.server.ts │ ├── client-hints.tsx │ ├── db.server.ts │ ├── devtools.tsx │ ├── email.server.ts │ ├── env.server.ts │ ├── forms.module.css │ ├── forms.tsx │ ├── misc.server.ts │ ├── misc.ts │ ├── monitoring.client.tsx │ ├── monitoring.server.ts │ ├── nonce-provider.ts │ ├── permissions.server.ts │ ├── request-info.ts │ ├── session.server.ts │ ├── singleton.server.ts │ ├── timing.server.ts │ ├── totp.server.test.ts │ ├── totp.server.ts │ ├── user-validation.ts │ ├── user.ts │ └── zod-extensions.ts ├── fly.toml ├── index.js ├── other ├── build-server.ts ├── litefs.yml ├── sentry-create-release.js └── setup-swap.js ├── package-lock.json ├── package.json ├── playwright.config.ts ├── postcss.config.js ├── prisma ├── migrations │ ├── 20230608211059_init │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── seed.ts ├── public ├── apple-touch-icon-120x120.png ├── apple-touch-icon-152x152-precomposed.png ├── apple-touch-icon-precomposed.png ├── apple-touch-icon.png ├── favicon.ico ├── favicons │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ └── favicon-32x32.png ├── fonts │ └── nunito-sans │ │ ├── nunito-sans-v12-latin_latin-ext-200.woff │ │ ├── nunito-sans-v12-latin_latin-ext-200.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-200italic.woff │ │ ├── nunito-sans-v12-latin_latin-ext-200italic.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-300.woff │ │ ├── nunito-sans-v12-latin_latin-ext-300.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-300italic.woff │ │ ├── nunito-sans-v12-latin_latin-ext-300italic.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-600.woff │ │ ├── nunito-sans-v12-latin_latin-ext-600.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-600italic.woff │ │ ├── nunito-sans-v12-latin_latin-ext-600italic.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-700.woff │ │ ├── nunito-sans-v12-latin_latin-ext-700.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-700italic.woff │ │ ├── nunito-sans-v12-latin_latin-ext-700italic.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-800.woff │ │ ├── nunito-sans-v12-latin_latin-ext-800.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-800italic.woff │ │ ├── nunito-sans-v12-latin_latin-ext-800italic.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-900.woff │ │ ├── nunito-sans-v12-latin_latin-ext-900.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-900italic.woff │ │ ├── nunito-sans-v12-latin_latin-ext-900italic.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-italic.woff │ │ ├── nunito-sans-v12-latin_latin-ext-italic.woff2 │ │ ├── nunito-sans-v12-latin_latin-ext-regular.woff │ │ └── nunito-sans-v12-latin_latin-ext-regular.woff2 ├── img │ └── user.png ├── robots.txt └── site.webmanifest ├── remix.config.js ├── server ├── dev-server.js └── index.ts ├── tailwind.config.ts ├── tests ├── db-utils.ts ├── e2e │ ├── 2fa.test.ts │ ├── onboarding.test.ts │ └── settings-profile.test.ts ├── fixtures │ ├── images │ │ └── user │ │ │ ├── 0.jpg │ │ │ ├── 1.jpg │ │ │ ├── 2.jpg │ │ │ ├── 3.jpg │ │ │ ├── 4.jpg │ │ │ ├── 5.jpg │ │ │ ├── 6.jpg │ │ │ ├── 7.jpg │ │ │ ├── 8.jpg │ │ │ ├── 9.jpg │ │ │ ├── README.md │ │ │ └── kody.png │ └── test-profile.jpg ├── memoize-unique.ts ├── mocks │ ├── README.md │ ├── index.ts │ └── utils.ts ├── playwright-utils.ts ├── setup │ ├── global-setup.ts │ ├── matchers.cjs │ ├── paths.ts │ ├── setup-env-vars.ts │ ├── setup-test-env.ts │ ├── utils.ts │ └── vitejs-plugin-react.cjs ├── vitest-utils.ts └── vitest.d.ts ├── tsconfig.json ├── types ├── deps.d.ts ├── remix.env.d.ts └── reset.d.ts └── vitest.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | *.log 3 | .DS_Store 4 | .env 5 | /.cache 6 | /public/build 7 | /build 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | LITEFS_DIR="/litefs/data" 2 | DATABASE_PATH="./prisma/data.db" 3 | DATABASE_URL="file:./data.db?connection_limit=1" 4 | CACHE_DATABASE_PATH="./other/cache.db" 5 | SESSION_SECRET="super-duper-s3cret" 6 | INTERNAL_COMMAND_TOKEN="some-made-up-token" 7 | MAILGUN_DOMAIN="mg.example.com" 8 | MAILGUN_SENDING_KEY="some-api-token-with-dashes" 9 | SENTRY_DSN="your-dsn" 10 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | const vitestFiles = ['app/**/__tests__/**/*', 'app/**/*.{spec,test}.*'] 2 | const testFiles = ['**/tests/**', ...vitestFiles] 3 | const appFiles = ['app/**'] 4 | 5 | /** @type {import('@types/eslint').Linter.BaseConfig} */ 6 | module.exports = { 7 | extends: [ 8 | '@remix-run/eslint-config', 9 | '@remix-run/eslint-config/node', 10 | 'prettier', 11 | ], 12 | rules: { 13 | '@typescript-eslint/consistent-type-imports': [ 14 | 'warn', 15 | { 16 | prefer: 'type-imports', 17 | disallowTypeAnnotations: true, 18 | fixStyle: 'inline-type-imports', 19 | }, 20 | ], 21 | '@typescript-eslint/no-duplicate-imports': 'warn', 22 | }, 23 | overrides: [ 24 | { 25 | plugins: ['remix-react-routes'], 26 | files: appFiles, 27 | excludedFiles: testFiles, 28 | rules: { 29 | 'remix-react-routes/use-link-for-routes': 'error', 30 | 'remix-react-routes/require-valid-paths': 'error', 31 | // disable this one because it doesn't appear to work with our 32 | // route convention. Someone should dig deeper into this... 33 | 'remix-react-routes/no-relative-paths': [ 34 | 'off', 35 | { allowLinksToSelf: true }, 36 | ], 37 | 'remix-react-routes/no-urls': 'error', 38 | 'no-restricted-imports': [ 39 | 'error', 40 | { 41 | patterns: [ 42 | { 43 | group: testFiles, 44 | message: 'Do not import test files in app files', 45 | }, 46 | ], 47 | }, 48 | ], 49 | }, 50 | }, 51 | { 52 | extends: ['@remix-run/eslint-config/jest-testing-library'], 53 | files: vitestFiles, 54 | rules: { 55 | 'testing-library/no-await-sync-events': 'off', 56 | 'jest-dom/prefer-in-document': 'off', 57 | }, 58 | // we're using vitest which has a very similar API to jest 59 | // (so the linting plugins work nicely), but it means we have to explicitly 60 | // set the jest version. 61 | settings: { 62 | jest: { 63 | version: 28, 64 | }, 65 | }, 66 | }, 67 | ], 68 | } 69 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Test Plan 4 | 5 | 6 | 7 | ## Checklist 8 | 9 | - [ ] Tests updated 10 | - [ ] Docs updated 11 | 12 | ## Screenshots 13 | 14 | 16 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Deploy 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - dev 7 | pull_request: {} 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | permissions: 14 | actions: write 15 | contents: read 16 | 17 | jobs: 18 | lint: 19 | name: ⬣ ESLint 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: ⬇️ Checkout repo 23 | uses: actions/checkout@v3 24 | 25 | - name: ⎔ Setup node 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version: 18 29 | 30 | - name: 📥 Download deps 31 | uses: bahmutov/npm-install@v1 32 | 33 | - name: 🔬 Lint 34 | run: npm run lint 35 | 36 | typecheck: 37 | name: ʦ TypeScript 38 | runs-on: ubuntu-latest 39 | steps: 40 | - name: ⬇️ Checkout repo 41 | uses: actions/checkout@v3 42 | 43 | - name: ⎔ Setup node 44 | uses: actions/setup-node@v3 45 | with: 46 | node-version: 18 47 | 48 | - name: 📥 Download deps 49 | uses: bahmutov/npm-install@v1 50 | 51 | - name: 🔎 Type check 52 | run: npm run typecheck --if-present 53 | 54 | vitest: 55 | name: ⚡ Vitest 56 | runs-on: ubuntu-latest 57 | steps: 58 | - name: ⬇️ Checkout repo 59 | uses: actions/checkout@v3 60 | 61 | - name: ⎔ Setup node 62 | uses: actions/setup-node@v3 63 | with: 64 | node-version: 18 65 | 66 | - name: 📥 Download deps 67 | uses: bahmutov/npm-install@v1 68 | 69 | - name: 🏄 Copy test env vars 70 | run: cp .env.example .env 71 | 72 | - name: ⚡ Run vitest 73 | run: npm run test -- --coverage 74 | 75 | playwright: 76 | name: 🎭 Playwright 77 | runs-on: ubuntu-latest 78 | timeout-minutes: 60 79 | steps: 80 | - name: ⬇️ Checkout repo 81 | uses: actions/checkout@v3 82 | 83 | - name: 🏄 Copy test env vars 84 | run: cp .env.example .env 85 | 86 | - name: ⎔ Setup node 87 | uses: actions/setup-node@v3 88 | with: 89 | node-version: 18 90 | 91 | - name: 📥 Download deps 92 | uses: bahmutov/npm-install@v1 93 | 94 | - name: 📥 Install Playwright Browsers 95 | run: npx playwright install --with-deps 96 | 97 | - name: 🛠 Setup Database 98 | run: npx prisma migrate deploy 99 | 100 | - name: 🏦 Cache Database 101 | id: db-cache 102 | uses: actions/cache@v3 103 | with: 104 | path: prisma/data.db 105 | key: db-cache 106 | 107 | - name: 🏗 Build 108 | run: npm run build 109 | 110 | - name: 🎭 Playwright tests 111 | run: npx playwright test 112 | 113 | - name: 📊 Upload report 114 | uses: actions/upload-artifact@v3 115 | if: always() 116 | with: 117 | name: playwright-report 118 | path: playwright-report/ 119 | retention-days: 30 120 | 121 | deploy: 122 | name: 🚀 Deploy 123 | runs-on: ubuntu-latest 124 | needs: [lint, typecheck, vitest, playwright] 125 | # only build/deploy main branch on pushes 126 | if: 127 | ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && 128 | github.event_name == 'push' }} 129 | 130 | steps: 131 | - name: ⬇️ Checkout repo 132 | uses: actions/checkout@v3 133 | 134 | - name: 👀 Read app name 135 | uses: SebRollen/toml-action@v1.0.2 136 | id: app_name 137 | with: 138 | file: 'fly.toml' 139 | field: 'app' 140 | 141 | # - name: 🚀 Deploy Staging 142 | # if: ${{ github.ref == 'refs/heads/dev' }} 143 | # uses: superfly/flyctl-actions@1.3 144 | # with: 145 | # args: 146 | # 'deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} --app 147 | # ${{ steps.app_name.outputs.value }}-staging' 148 | # env: 149 | # FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 150 | 151 | # - name: 🚀 Deploy Production 152 | # if: ${{ github.ref == 'refs/heads/main' }} 153 | # uses: superfly/flyctl-actions@1.3 154 | # with: 155 | # args: 'deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }}' 156 | # env: 157 | # FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 158 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | /server-build 6 | .env 7 | 8 | /prisma/data.db 9 | /prisma/data.db-journal 10 | /tests/prisma 11 | 12 | /test-results/ 13 | /playwright-report/ 14 | /playwright/.cache/ 15 | /tests/fixtures/email/ 16 | /coverage 17 | 18 | /other/cache.db 19 | 20 | # Easy way to create temporary files/folders that won't accidentally be added to git 21 | *.local.* 22 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | registry=https://registry.npmjs.org/ 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | /server-build 6 | .env 7 | 8 | /test-results/ 9 | /playwright-report/ 10 | /playwright/.cache/ 11 | /tests/fixtures/email/*.json 12 | /coverage 13 | /prisma/migrations 14 | 15 | package-lock.json 16 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'avoid', 3 | bracketSameLine: false, 4 | bracketSpacing: true, 5 | embeddedLanguageFormatting: 'auto', 6 | endOfLine: 'lf', 7 | htmlWhitespaceSensitivity: 'css', 8 | insertPragma: false, 9 | jsxSingleQuote: false, 10 | printWidth: 80, 11 | proseWrap: 'always', 12 | quoteProps: 'as-needed', 13 | requirePragma: false, 14 | semi: false, 15 | singleAttributePerLine: false, 16 | singleQuote: true, 17 | tabWidth: 2, 18 | trailingComma: 'all', 19 | useTabs: true, 20 | overrides: [ 21 | { 22 | files: ['**/*.json'], 23 | options: { 24 | useTabs: false, 25 | }, 26 | }, 27 | ], 28 | } 29 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # base node image 2 | FROM node:18-bullseye-slim as base 3 | 4 | # set for base and all layer that inherit from it 5 | ENV NODE_ENV production 6 | 7 | # Install openssl for Prisma 8 | RUN apt-get update && apt-get install -y fuse3 openssl sqlite3 ca-certificates 9 | 10 | # Install all node_modules, including dev dependencies 11 | FROM base as deps 12 | 13 | WORKDIR /myapp 14 | 15 | ADD package.json package-lock.json .npmrc ./ 16 | RUN npm install --include=dev 17 | 18 | # Setup production node_modules 19 | FROM base as production-deps 20 | 21 | WORKDIR /myapp 22 | 23 | COPY --from=deps /myapp/node_modules /myapp/node_modules 24 | ADD package.json package-lock.json .npmrc ./ 25 | RUN npm prune --omit=dev 26 | 27 | # Build the app 28 | FROM base as build 29 | 30 | WORKDIR /myapp 31 | 32 | COPY --from=deps /myapp/node_modules /myapp/node_modules 33 | 34 | ADD prisma . 35 | RUN npx prisma generate 36 | 37 | ADD . . 38 | RUN npm run build 39 | 40 | # Finally, build the production image with minimal footprint 41 | FROM base 42 | 43 | ENV FLY="true" 44 | ENV LITEFS_DIR="/litefs/data" 45 | ENV DATABASE_FILENAME="sqlite.db" 46 | ENV DATABASE_PATH="$LITEFS_DIR/$DATABASE_FILENAME" 47 | ENV DATABASE_URL="file:$DATABASE_PATH" 48 | ENV CACHE_DATABASE_FILENAME="cache.db" 49 | ENV CACHE_DATABASE_PATH="/$LITEFS_DIR/$CACHE_DATABASE_FILENAME" 50 | ENV INTERNAL_PORT="8080" 51 | ENV PORT="8081" 52 | ENV NODE_ENV="production" 53 | 54 | # add shortcut for connecting to database CLI 55 | RUN echo "#!/bin/sh\nset -x\nsqlite3 \$DATABASE_URL" > /usr/local/bin/database-cli && chmod +x /usr/local/bin/database-cli 56 | 57 | WORKDIR /myapp 58 | 59 | COPY --from=production-deps /myapp/node_modules /myapp/node_modules 60 | COPY --from=build /myapp/node_modules/.prisma /myapp/node_modules/.prisma 61 | 62 | COPY --from=build /myapp/server-build /myapp/server-build 63 | COPY --from=build /myapp/build /myapp/build 64 | COPY --from=build /myapp/public /myapp/public 65 | COPY --from=build /myapp/package.json /myapp/package.json 66 | COPY --from=build /myapp/prisma /myapp/prisma 67 | 68 | # prepare for litefs 69 | COPY --from=flyio/litefs:0.4.0 /usr/local/bin/litefs /usr/local/bin/litefs 70 | ADD other/litefs.yml /etc/litefs.yml 71 | RUN mkdir -p /data ${LITEFS_DIR} 72 | 73 | ADD . . 74 | 75 | CMD ["litefs", "mount"] 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Epic Stack with CSRF Protection 2 | 3 | This is an example of how to integrate the 4 | [`remix-utils`](https://github.com/sergiodxa/remix-utils) package utilities for 5 | [Cross-Site Request Forgery (CSRF)](https://en.wikipedia.org/wiki/Cross-site_request_forgery) 6 | protection with the Epic Stack. The easiest way to explore the example is to 7 | pull up 8 | [the commit history](https://github.com/kentcdodds/epic-stack-with-csrf/commits/main). 9 | 10 | Following the steps laid out in the Remix Utils docs is sufficient for this: 11 | 12 | 1. Install `remix-utils` 13 | 2. Generate the authenticity token in the `root.tsx` loader (be certain to 14 | commit the session to set the cookie) 15 | 3. Wrap the App in the `` and provide the token 16 | 4. Render a Form with the `` component 17 | 5. Verify in the Action using `verifyAuthenticityToken` and the session. 18 | -------------------------------------------------------------------------------- /app/components/error-boundary.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | isRouteErrorResponse, 3 | useParams, 4 | useRouteError, 5 | } from '@remix-run/react' 6 | import { type ErrorResponse } from '@remix-run/router' 7 | import { getErrorMessage } from '~/utils/misc.ts' 8 | 9 | type StatusHandler = (info: { 10 | error: ErrorResponse 11 | params: Record 12 | }) => JSX.Element | null 13 | 14 | export function GeneralErrorBoundary({ 15 | defaultStatusHandler = ({ error }) => ( 16 |

17 | {error.status} {error.data} 18 |

19 | ), 20 | statusHandlers, 21 | unexpectedErrorHandler = error =>

{getErrorMessage(error)}

, 22 | }: { 23 | defaultStatusHandler?: StatusHandler 24 | statusHandlers?: Record 25 | unexpectedErrorHandler?: (error: unknown) => JSX.Element | null 26 | }) { 27 | const error = useRouteError() 28 | const params = useParams() 29 | 30 | if (typeof document !== 'undefined') { 31 | console.error(error) 32 | } 33 | 34 | return ( 35 |
36 | {isRouteErrorResponse(error) 37 | ? (statusHandlers?.[error.status] ?? defaultStatusHandler)({ 38 | error, 39 | params, 40 | }) 41 | : unexpectedErrorHandler(error)} 42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /app/components/spacer.tsx: -------------------------------------------------------------------------------- 1 | export function Spacer({ 2 | size, 3 | }: { 4 | /** 5 | * The size of the space 6 | * 7 | * 4xs: h-4 (16px) 8 | * 9 | * 3xs: h-8 (32px) 10 | * 11 | * 2xs: h-12 (48px) 12 | * 13 | * xs: h-16 (64px) 14 | * 15 | * sm: h-20 (80px) 16 | * 17 | * md: h-24 (96px) 18 | * 19 | * lg: h-28 (112px) 20 | * 21 | * xl: h-32 (128px) 22 | * 23 | * 2xl: h-36 (144px) 24 | * 25 | * 3xl: h-40 (160px) 26 | * 27 | * 4xl: h-44 (176px) 28 | */ 29 | size: 30 | | '4xs' 31 | | '3xs' 32 | | '2xs' 33 | | 'xs' 34 | | 'sm' 35 | | 'md' 36 | | 'lg' 37 | | 'xl' 38 | | '2xl' 39 | | '3xl' 40 | | '4xl' 41 | }) { 42 | const options: Record = { 43 | '4xs': 'h-4', 44 | '3xs': 'h-8', 45 | '2xs': 'h-12', 46 | xs: 'h-16', 47 | sm: 'h-20', 48 | md: 'h-24', 49 | lg: 'h-28', 50 | xl: 'h-32', 51 | '2xl': 'h-36', 52 | '3xl': 'h-40', 53 | '4xl': 'h-44', 54 | } 55 | const className = options[size] 56 | return
57 | } 58 | -------------------------------------------------------------------------------- /app/components/spinner.tsx: -------------------------------------------------------------------------------- 1 | export function Spinner({ showSpinner }: { showSpinner: boolean }) { 2 | return ( 3 |
8 | 16 | Loading 17 | 25 | 30 | 31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from '@remix-run/react' 2 | import { startTransition } from 'react' 3 | import { hydrateRoot } from 'react-dom/client' 4 | 5 | if (ENV.MODE === 'development') { 6 | import('~/utils/devtools.tsx').then(({ init }) => init()) 7 | } 8 | if (ENV.MODE === 'production' && ENV.SENTRY_DSN) { 9 | import('~/utils/monitoring.client.tsx').then(({ init }) => init()) 10 | } 11 | startTransition(() => { 12 | hydrateRoot(document, ) 13 | }) 14 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { Response, type HandleDocumentRequestFunction } from '@remix-run/node' 2 | import { RemixServer } from '@remix-run/react' 3 | import isbot from 'isbot' 4 | import { getInstanceInfo } from 'litefs-js' 5 | import { renderToPipeableStream } from 'react-dom/server' 6 | import { PassThrough } from 'stream' 7 | import { getEnv, init } from './utils/env.server.ts' 8 | import { NonceProvider } from './utils/nonce-provider.ts' 9 | 10 | const ABORT_DELAY = 5000 11 | 12 | init() 13 | global.ENV = getEnv() 14 | 15 | if (ENV.MODE === 'production' && ENV.SENTRY_DSN) { 16 | import('~/utils/monitoring.server.ts').then(({ init }) => init()) 17 | } 18 | 19 | type DocRequestArgs = Parameters 20 | 21 | export default async function handleRequest(...args: DocRequestArgs) { 22 | const [ 23 | request, 24 | responseStatusCode, 25 | responseHeaders, 26 | remixContext, 27 | loadContext, 28 | ] = args 29 | const { currentInstance, primaryInstance } = await getInstanceInfo() 30 | responseHeaders.set('fly-region', process.env.FLY_REGION ?? 'unknown') 31 | responseHeaders.set('fly-app', process.env.FLY_APP_NAME ?? 'unknown') 32 | responseHeaders.set('fly-primary-instance', primaryInstance) 33 | responseHeaders.set('fly-instance', currentInstance) 34 | 35 | const callbackName = isbot(request.headers.get('user-agent')) 36 | ? 'onAllReady' 37 | : 'onShellReady' 38 | 39 | const nonce = String(loadContext.cspNonce) ?? undefined 40 | return new Promise((resolve, reject) => { 41 | let didError = false 42 | 43 | const { pipe, abort } = renderToPipeableStream( 44 | 45 | 46 | , 47 | { 48 | [callbackName]: () => { 49 | const body = new PassThrough() 50 | responseHeaders.set('Content-Type', 'text/html') 51 | resolve( 52 | new Response(body, { 53 | headers: responseHeaders, 54 | status: didError ? 500 : responseStatusCode, 55 | }), 56 | ) 57 | pipe(body) 58 | }, 59 | onShellError: (err: unknown) => { 60 | reject(err) 61 | }, 62 | onError: (error: unknown) => { 63 | didError = true 64 | 65 | console.error(error) 66 | }, 67 | }, 68 | ) 69 | 70 | setTimeout(abort, ABORT_DELAY) 71 | }) 72 | } 73 | 74 | export async function handleDataRequest(response: Response) { 75 | const { currentInstance, primaryInstance } = await getInstanceInfo() 76 | response.headers.set('fly-region', process.env.FLY_REGION ?? 'unknown') 77 | response.headers.set('fly-app', process.env.FLY_APP_NAME ?? 'unknown') 78 | response.headers.set('fly-primary-instance', primaryInstance) 79 | response.headers.set('fly-instance', currentInstance) 80 | 81 | return response 82 | } 83 | -------------------------------------------------------------------------------- /app/routes/_auth+/login.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | json, 3 | type DataFunctionArgs, 4 | type V2_MetaFunction, 5 | } from '@remix-run/node' 6 | import { useLoaderData, useSearchParams } from '@remix-run/react' 7 | import { GeneralErrorBoundary } from '~/components/error-boundary.tsx' 8 | import { Spacer } from '~/components/spacer.tsx' 9 | import { authenticator, requireAnonymous } from '~/utils/auth.server.ts' 10 | import { commitSession, getSession } from '~/utils/session.server.ts' 11 | import { InlineLogin } from '../resources+/login.tsx' 12 | import { Verifier, unverifiedSessionKey } from '../resources+/verify.tsx' 13 | 14 | export async function loader({ request }: DataFunctionArgs) { 15 | await requireAnonymous(request) 16 | const session = await getSession(request.headers.get('cookie')) 17 | const error = session.get(authenticator.sessionErrorKey) 18 | let errorMessage: string | null = null 19 | if (typeof error?.message === 'string') { 20 | errorMessage = error.message 21 | } 22 | return json( 23 | { formError: errorMessage, unverified: session.has(unverifiedSessionKey) }, 24 | { 25 | headers: { 26 | 'Set-Cookie': await commitSession(session), 27 | }, 28 | }, 29 | ) 30 | } 31 | 32 | export const meta: V2_MetaFunction = () => { 33 | return [{ title: 'Login to Epic Notes' }] 34 | } 35 | 36 | export default function LoginPage() { 37 | const [searchParams] = useSearchParams() 38 | const data = useLoaderData() 39 | 40 | const redirectTo = searchParams.get('redirectTo') || '/' 41 | 42 | return ( 43 |
44 |
45 |
46 |

Welcome back!

47 |

48 | Please enter your details. 49 |

50 |
51 | 52 | {data.unverified ? ( 53 | 54 | ) : ( 55 | 56 | )} 57 |
58 |
59 | ) 60 | } 61 | 62 | export function ErrorBoundary() { 63 | return 64 | } 65 | -------------------------------------------------------------------------------- /app/routes/_auth+/logout.tsx: -------------------------------------------------------------------------------- 1 | import { redirect, type DataFunctionArgs } from '@remix-run/node' 2 | import { authenticator } from '~/utils/auth.server.ts' 3 | 4 | export async function action({ request }: DataFunctionArgs) { 5 | await authenticator.logout(request, { redirectTo: '/' }) 6 | } 7 | 8 | export async function loader() { 9 | return redirect('/') 10 | } 11 | -------------------------------------------------------------------------------- /app/routes/_auth+/reset-password.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | json, 3 | redirect, 4 | type DataFunctionArgs, 5 | type V2_MetaFunction, 6 | } from '@remix-run/node' 7 | import { 8 | Form, 9 | useActionData, 10 | useFormAction, 11 | useLoaderData, 12 | useNavigation, 13 | } from '@remix-run/react' 14 | import { z } from 'zod' 15 | import { GeneralErrorBoundary } from '~/components/error-boundary.tsx' 16 | import { 17 | authenticator, 18 | requireAnonymous, 19 | resetUserPassword, 20 | } from '~/utils/auth.server.ts' 21 | import { Button, ErrorList, Field } from '~/utils/forms.tsx' 22 | import { conform, useForm } from '@conform-to/react' 23 | import { getFieldsetConstraint, parse } from '@conform-to/zod' 24 | import { commitSession, getSession } from '~/utils/session.server.ts' 25 | import { passwordSchema } from '~/utils/user-validation.ts' 26 | 27 | export const resetPasswordUsernameSessionKey = 'resetPasswordUsername' 28 | 29 | const resetPasswordSchema = z 30 | .object({ 31 | password: passwordSchema, 32 | confirmPassword: passwordSchema, 33 | }) 34 | .refine(({ confirmPassword, password }) => password === confirmPassword, { 35 | message: 'The passwords did not match', 36 | path: ['confirmPassword'], 37 | }) 38 | 39 | export async function loader({ request }: DataFunctionArgs) { 40 | await requireAnonymous(request) 41 | const session = await getSession(request.headers.get('cookie')) 42 | const error = session.get(authenticator.sessionErrorKey) 43 | const resetPasswordUsername = session.get(resetPasswordUsernameSessionKey) 44 | if (typeof resetPasswordUsername !== 'string' || !resetPasswordUsername) { 45 | return redirect('/login') 46 | } 47 | return json( 48 | { 49 | formError: error?.message, 50 | resetPasswordUsername, 51 | }, 52 | { 53 | headers: { 'Set-Cookie': await commitSession(session) }, 54 | }, 55 | ) 56 | } 57 | 58 | export async function action({ request }: DataFunctionArgs) { 59 | const formData = await request.formData() 60 | const submission = parse(formData, { 61 | schema: resetPasswordSchema, 62 | acceptMultipleErrors: () => true, 63 | }) 64 | if (submission.intent !== 'submit') { 65 | return json({ status: 'idle', submission } as const) 66 | } 67 | if (!submission.value) { 68 | return json( 69 | { 70 | status: 'error', 71 | submission, 72 | } as const, 73 | { status: 400 }, 74 | ) 75 | } 76 | const { password } = submission.value 77 | 78 | const session = await getSession(request.headers.get('cookie')) 79 | const username = session.get(resetPasswordUsernameSessionKey) 80 | if (typeof username !== 'string' || !username) { 81 | return redirect('/login') 82 | } 83 | await resetUserPassword({ username, password }) 84 | session.unset(resetPasswordUsernameSessionKey) 85 | return redirect('/login', { 86 | headers: { 'Set-Cookie': await commitSession(session) }, 87 | }) 88 | } 89 | 90 | export const meta: V2_MetaFunction = () => { 91 | return [{ title: 'Reset Password | Epic Notes' }] 92 | } 93 | 94 | export default function ResetPasswordPage() { 95 | const data = useLoaderData() 96 | const actionData = useActionData() 97 | const formAction = useFormAction() 98 | const navigation = useNavigation() 99 | 100 | const [form, fields] = useForm({ 101 | id: 'reset-password', 102 | constraint: getFieldsetConstraint(resetPasswordSchema), 103 | lastSubmission: actionData?.submission, 104 | onValidate({ formData }) { 105 | return parse(formData, { schema: resetPasswordSchema }) 106 | }, 107 | shouldRevalidate: 'onBlur', 108 | }) 109 | 110 | return ( 111 |
112 |
113 |

Password Reset

114 |

115 | Hi, {data.resetPasswordUsername}. No worries. It happens all the time. 116 |

117 |
118 |
123 | 134 | 145 | 146 | 147 | 148 | 164 | 165 |
166 | ) 167 | } 168 | 169 | export function ErrorBoundary() { 170 | return 171 | } 172 | -------------------------------------------------------------------------------- /app/routes/_auth+/signup.tsx: -------------------------------------------------------------------------------- 1 | import { conform, useForm } from '@conform-to/react' 2 | import { getFieldsetConstraint, parse } from '@conform-to/zod' 3 | import { 4 | json, 5 | redirect, 6 | type DataFunctionArgs, 7 | type V2_MetaFunction, 8 | } from '@remix-run/node' 9 | import { 10 | Form, 11 | useActionData, 12 | useFormAction, 13 | useNavigation, 14 | } from '@remix-run/react' 15 | import { z } from 'zod' 16 | import { GeneralErrorBoundary } from '~/components/error-boundary.tsx' 17 | import { prisma } from '~/utils/db.server.ts' 18 | import { sendEmail } from '~/utils/email.server.ts' 19 | import { Button, ErrorList, Field } from '~/utils/forms.tsx' 20 | import { getDomainUrl } from '~/utils/misc.server.ts' 21 | import { generateTOTP } from '~/utils/totp.server.ts' 22 | import { emailSchema } from '~/utils/user-validation.ts' 23 | 24 | export const onboardingOTPQueryParam = 'code' 25 | export const onboardingEmailQueryParam = 'email' 26 | export const verificationType = 'onboarding' 27 | 28 | const signupSchema = z.object({ 29 | email: emailSchema, 30 | }) 31 | 32 | export async function action({ request }: DataFunctionArgs) { 33 | const formData = await request.formData() 34 | const submission = await parse(formData, { 35 | schema: () => { 36 | return signupSchema.superRefine(async (data, ctx) => { 37 | const existingUser = await prisma.user.findUnique({ 38 | where: { email: data.email }, 39 | select: { id: true }, 40 | }) 41 | if (existingUser) { 42 | ctx.addIssue({ 43 | path: ['email'], 44 | code: z.ZodIssueCode.custom, 45 | message: 'A user already exists with this email', 46 | }) 47 | return 48 | } 49 | }) 50 | }, 51 | acceptMultipleErrors: () => true, 52 | async: true, 53 | }) 54 | if (submission.intent !== 'submit') { 55 | return json({ status: 'idle', submission } as const) 56 | } 57 | if (!submission.value) { 58 | return json( 59 | { 60 | status: 'error', 61 | submission, 62 | } as const, 63 | { status: 400 }, 64 | ) 65 | } 66 | const { email } = submission.value 67 | 68 | const thirtyMinutesInSeconds = 30 * 60 69 | const { otp, secret, algorithm, period, digits } = generateTOTP({ 70 | algorithm: 'sha256', 71 | period: thirtyMinutesInSeconds, 72 | }) 73 | // delete old verifications. Users should not have more than one verification 74 | // of a specific type for a specific target at a time. 75 | await prisma.verification.deleteMany({ 76 | where: { type: verificationType, target: email }, 77 | }) 78 | await prisma.verification.create({ 79 | data: { 80 | type: verificationType, 81 | target: email, 82 | algorithm, 83 | secret, 84 | period, 85 | digits, 86 | expiresAt: new Date(Date.now() + period * 1000), 87 | }, 88 | }) 89 | const onboardingUrl = new URL(`${getDomainUrl(request)}/signup/verify`) 90 | onboardingUrl.searchParams.set(onboardingEmailQueryParam, email) 91 | const redirectTo = new URL(onboardingUrl.toString()) 92 | 93 | // add the otp to the url we'll email the user. 94 | onboardingUrl.searchParams.set(onboardingOTPQueryParam, otp) 95 | 96 | const response = await sendEmail({ 97 | to: email, 98 | subject: `Welcome to Epic Notes!`, 99 | text: ` 100 | Welcome to Epic Notes! 101 | Here's your verification code: ${otp} 102 | Or you can open this URL: ${onboardingUrl} 103 | `.trim(), 104 | html: ` 105 | 106 | 107 | 108 | 109 | 110 | 111 |

Welcome to Epic Notes!

112 |

Here's your verification code: ${otp}

113 |

Or click the link to get started:

114 | ${onboardingUrl} 115 | 116 | 117 | `, 118 | }) 119 | 120 | if (response?.ok) { 121 | return redirect(redirectTo.pathname + redirectTo.search) 122 | } else { 123 | return json( 124 | { 125 | status: 'error', 126 | submission, 127 | } as const, 128 | { status: 500 }, 129 | ) 130 | } 131 | } 132 | 133 | export const meta: V2_MetaFunction = () => { 134 | return [{ title: 'Sign Up | Epic Notes' }] 135 | } 136 | 137 | export default function SignupRoute() { 138 | const actionData = useActionData() 139 | const formAction = useFormAction() 140 | const navigation = useNavigation() 141 | const isSubmitting = navigation.formAction === formAction 142 | const [form, fields] = useForm({ 143 | id: 'signup-form', 144 | constraint: getFieldsetConstraint(signupSchema), 145 | lastSubmission: actionData?.submission, 146 | onValidate({ formData }) { 147 | const result = parse(formData, { schema: signupSchema }) 148 | return result 149 | }, 150 | shouldRevalidate: 'onBlur', 151 | }) 152 | 153 | return ( 154 |
155 |
156 |

Let's start your journey!

157 |

158 | Please enter your email. 159 |

160 |
161 |
166 | 174 | 175 | 185 | 186 |
187 | ) 188 | } 189 | 190 | export function ErrorBoundary() { 191 | return 192 | } 193 | -------------------------------------------------------------------------------- /app/routes/_auth+/signup_.verify.tsx: -------------------------------------------------------------------------------- 1 | import { conform, useForm } from '@conform-to/react' 2 | import { getFieldsetConstraint, parse } from '@conform-to/zod' 3 | import { json, redirect, type DataFunctionArgs } from '@remix-run/node' 4 | import { 5 | Form, 6 | useActionData, 7 | useFormAction, 8 | useLoaderData, 9 | useNavigation, 10 | } from '@remix-run/react' 11 | import { z } from 'zod' 12 | import { prisma } from '~/utils/db.server.ts' 13 | import { Button, ErrorList, Field } from '~/utils/forms.tsx' 14 | import { verifyTOTP } from '~/utils/totp.server.ts' 15 | import { 16 | onboardingEmailQueryParam, 17 | onboardingOTPQueryParam, 18 | verificationType, 19 | } from './signup.tsx' 20 | import { commitSession, getSession } from '~/utils/session.server.ts' 21 | import { onboardingEmailSessionKey } from './onboarding.tsx' 22 | 23 | const verifySchema = z.object({ 24 | [onboardingEmailQueryParam]: z.string().email(), 25 | [onboardingOTPQueryParam]: z.string().min(6).max(6), 26 | }) 27 | 28 | export async function loader({ request }: DataFunctionArgs) { 29 | const params = new URL(request.url).searchParams 30 | if (!params.has(onboardingOTPQueryParam)) { 31 | // we don't want to show an error message on page load if the otp hasn't be 32 | // prefilled in yet, so we'll send a response with an empty submission. 33 | return json({ 34 | status: 'idle', 35 | submission: { 36 | intent: '', 37 | payload: Object.fromEntries(params), 38 | error: {}, 39 | }, 40 | } as const) 41 | } 42 | return validate(request, params) 43 | } 44 | 45 | export async function action({ request }: DataFunctionArgs) { 46 | return validate(request, await request.formData()) 47 | } 48 | 49 | async function validate(request: Request, body: URLSearchParams | FormData) { 50 | const submission = await parse(body, { 51 | schema: () => 52 | verifySchema.superRefine(async (data, ctx) => { 53 | const verification = await prisma.verification.findFirst({ 54 | where: { 55 | type: verificationType, 56 | target: data.email, 57 | expiresAt: { gt: new Date() }, 58 | }, 59 | select: { 60 | algorithm: true, 61 | secret: true, 62 | period: true, 63 | }, 64 | }) 65 | if (!verification) { 66 | ctx.addIssue({ 67 | path: [onboardingOTPQueryParam], 68 | code: z.ZodIssueCode.custom, 69 | message: `Invalid code`, 70 | }) 71 | return 72 | } 73 | const result = verifyTOTP({ 74 | otp: data.code, 75 | secret: verification.secret, 76 | algorithm: verification.algorithm, 77 | period: verification.period, 78 | window: 0, 79 | }) 80 | if (!result) { 81 | ctx.addIssue({ 82 | path: [onboardingOTPQueryParam], 83 | code: z.ZodIssueCode.custom, 84 | message: `Invalid code`, 85 | }) 86 | return 87 | } 88 | }), 89 | acceptMultipleErrors: () => true, 90 | async: true, 91 | }) 92 | if (submission.intent !== 'submit') { 93 | return json({ status: 'idle', submission } as const) 94 | } 95 | if (!submission.value) { 96 | return json( 97 | { 98 | status: 'error', 99 | submission, 100 | } as const, 101 | { status: 400 }, 102 | ) 103 | } 104 | await prisma.verification.deleteMany({ 105 | where: { 106 | type: verificationType, 107 | target: submission.value.email, 108 | }, 109 | }) 110 | const session = await getSession(request.headers.get('Cookie')) 111 | session.set(onboardingEmailSessionKey, submission.value.email) 112 | return redirect('/onboarding', { 113 | headers: { 'Set-Cookie': await commitSession(session) }, 114 | }) 115 | } 116 | 117 | export default function SignupVerifyRoute() { 118 | const data = useLoaderData() 119 | const formAction = useFormAction() 120 | const navigation = useNavigation() 121 | const isSubmitting = navigation.formAction === formAction 122 | const actionData = useActionData() 123 | 124 | const [form, fields] = useForm({ 125 | id: 'signup-verify-form', 126 | constraint: getFieldsetConstraint(verifySchema), 127 | lastSubmission: actionData?.submission ?? data.submission, 128 | onValidate({ formData }) { 129 | return parse(formData, { schema: verifySchema }) 130 | }, 131 | shouldRevalidate: 'onBlur', 132 | }) 133 | 134 | return ( 135 |
136 |
137 |

Check your email

138 |

139 | We've sent you a code to verify your email address. 140 |

141 |
142 | 143 |
148 | 158 | 168 | 169 | 179 | 180 |
181 | ) 182 | } 183 | -------------------------------------------------------------------------------- /app/routes/_marketing+/about.tsx: -------------------------------------------------------------------------------- 1 | export default function AboutRoute() { 2 | return
About page
3 | } 4 | -------------------------------------------------------------------------------- /app/routes/_marketing+/index.tsx: -------------------------------------------------------------------------------- 1 | import type { V2_MetaFunction } from '@remix-run/node' 2 | import { kodyRocket, logos, stars } from './logos/logos.ts' 3 | 4 | export const meta: V2_MetaFunction = () => [{ title: 'Epic Notes' }] 5 | 6 | export default function Index() { 7 | return ( 8 |
9 |
10 |
11 |
12 |
13 | 14 |
15 |
16 |
17 |

18 | 22 | Epic Stack 23 | 24 |

25 |

26 | Check the{' '} 27 | 31 | Getting Started 32 | {' '} 33 | guide file for instructions on how to get your project off the 34 | ground! 35 |

36 | 37 | Illustration of a Koala riding a rocket 42 | 43 |
44 |
45 |
46 | 47 |
48 |
49 | {logos.map(img => ( 50 | 55 | {img.alt} 56 | 57 | ))} 58 |
59 |
60 |
61 |
62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/docker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/epic-stack-with-csrf/1d6693fa657a72bd598c9d51c956f9287c2485a9/app/routes/_marketing+/logos/docker.png -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/eslint.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 10 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/kody-rocket.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/epic-stack-with-csrf/1d6693fa657a72bd598c9d51c956f9287c2485a9/app/routes/_marketing+/logos/kody-rocket.png -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/logos.ts: -------------------------------------------------------------------------------- 1 | import remix from './remix.png' 2 | import fly from './fly.svg' 3 | import sqlite from './sqlite.svg' 4 | import prisma from './prisma.svg' 5 | import zod from './zod.svg' 6 | import github from './github.svg' 7 | import mailgun from './mailgun.png' 8 | import tailwind from './tailwind.svg' 9 | import radixUI from './radix.svg' 10 | import playwright from './playwright.svg' 11 | import msw from './msw.svg' 12 | import fakerJS from './faker.svg' 13 | import vitest from './vitest.svg' 14 | import testingLibrary from './testing-library.png' 15 | import docker from './docker.png' 16 | import typescript from './typescript.svg' 17 | import prettier from './prettier.svg' 18 | import eslint from './eslint.svg' 19 | import sentry from './sentry.svg' 20 | 21 | export { default as stars } from './stars.jpg' 22 | 23 | export { default as kodyRocket } from './kody-rocket.png' 24 | 25 | export const logos = [ 26 | { 27 | src: remix, 28 | alt: 'Remix', 29 | href: 'https://remix.run', 30 | }, 31 | { 32 | src: fly, 33 | alt: 'Fly.io', 34 | href: 'https://fly.io', 35 | }, 36 | { 37 | src: sqlite, 38 | alt: 'SQLite', 39 | href: 'https://sqlite.org', 40 | }, 41 | { 42 | src: prisma, 43 | alt: 'Prisma', 44 | href: 'https://prisma.io', 45 | }, 46 | { 47 | src: zod, 48 | alt: 'Zod', 49 | href: 'https://zod.dev/', 50 | }, 51 | { 52 | src: github, 53 | alt: 'GitHub', 54 | href: 'https://github.com', 55 | }, 56 | { 57 | src: mailgun, 58 | alt: 'Mailgun', 59 | href: 'https://mailgun.com', 60 | }, 61 | { 62 | src: tailwind, 63 | alt: 'Tailwind', 64 | href: 'https://tailwindcss.com', 65 | }, 66 | { 67 | src: radixUI, 68 | alt: 'Radix UI', 69 | href: 'https://www.radix-ui.com/', 70 | }, 71 | { 72 | src: playwright, 73 | alt: 'Playwright', 74 | href: 'https://playwright.dev/', 75 | }, 76 | { 77 | src: msw, 78 | alt: 'MSW', 79 | href: 'https://mswjs.io', 80 | }, 81 | { 82 | src: fakerJS, 83 | alt: 'Faker.js', 84 | href: 'https://fakerjs.dev/', 85 | }, 86 | { 87 | src: vitest, 88 | alt: 'Vitest', 89 | href: 'https://vitest.dev', 90 | }, 91 | { 92 | src: testingLibrary, 93 | alt: 'Testing Library', 94 | href: 'https://testing-library.com', 95 | }, 96 | { 97 | src: docker, 98 | alt: 'Docker', 99 | href: 'https://www.docker.com', 100 | }, 101 | { 102 | src: typescript, 103 | alt: 'TypeScript', 104 | href: 'https://typescriptlang.org', 105 | }, 106 | { 107 | src: prettier, 108 | alt: 'Prettier', 109 | href: 'https://prettier.io', 110 | }, 111 | { 112 | src: eslint, 113 | alt: 'ESLint', 114 | href: 'https://eslint.org', 115 | }, 116 | { 117 | src: sentry, 118 | alt: 'Sentry', 119 | href: 'https://sentry.io', 120 | }, 121 | ] 122 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/mailgun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/epic-stack-with-csrf/1d6693fa657a72bd598c9d51c956f9287c2485a9/app/routes/_marketing+/logos/mailgun.png -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/msw.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | LOGO 4 | 13 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/playwright.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/prisma.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/radix.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/remix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/epic-stack-with-csrf/1d6693fa657a72bd598c9d51c956f9287c2485a9/app/routes/_marketing+/logos/remix.png -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/sentry.svg: -------------------------------------------------------------------------------- 1 | 3 | 6 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/stars.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/epic-stack-with-csrf/1d6693fa657a72bd598c9d51c956f9287c2485a9/app/routes/_marketing+/logos/stars.jpg -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/tailwind.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/testing-library.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/epic-stack-with-csrf/1d6693fa657a72bd598c9d51c956f9287c2485a9/app/routes/_marketing+/logos/testing-library.png -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/typescript.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | TypeScript logo 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/routes/_marketing+/logos/vitest.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/routes/_marketing+/privacy.tsx: -------------------------------------------------------------------------------- 1 | export default function PrivacyRoute() { 2 | return
Privacy
3 | } 4 | -------------------------------------------------------------------------------- /app/routes/_marketing+/support.tsx: -------------------------------------------------------------------------------- 1 | export default function SupportRoute() { 2 | return
Support
3 | } 4 | -------------------------------------------------------------------------------- /app/routes/_marketing+/tos.tsx: -------------------------------------------------------------------------------- 1 | export default function TermsOfServiceRoute() { 2 | return
Terms of service
3 | } 4 | -------------------------------------------------------------------------------- /app/routes/admin+/cache_.lru.$cacheKey.ts: -------------------------------------------------------------------------------- 1 | import type { DataFunctionArgs } from '@remix-run/node' 2 | import { json } from '@remix-run/node' 3 | import invariant from 'tiny-invariant' 4 | import { getAllInstances, getInstanceInfo } from 'litefs-js' 5 | import { ensureInstance } from 'litefs-js/remix.js' 6 | import { lruCache } from '~/utils/cache.server.ts' 7 | import { requireAdmin } from '~/utils/permissions.server.ts' 8 | 9 | export async function loader({ request, params }: DataFunctionArgs) { 10 | await requireAdmin(request) 11 | const searchParams = new URL(request.url).searchParams 12 | const currentInstanceInfo = await getInstanceInfo() 13 | const allInstances = await getAllInstances() 14 | const instance = 15 | searchParams.get('instance') ?? currentInstanceInfo.currentInstance 16 | await ensureInstance(instance) 17 | 18 | const { cacheKey } = params 19 | invariant(cacheKey, 'cacheKey is required') 20 | return json({ 21 | instance: { 22 | hostname: instance, 23 | region: allInstances[instance], 24 | isPrimary: currentInstanceInfo.primaryInstance === instance, 25 | }, 26 | cacheKey, 27 | value: lruCache.get(cacheKey), 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /app/routes/admin+/cache_.sqlite.$cacheKey.ts: -------------------------------------------------------------------------------- 1 | import type { DataFunctionArgs } from '@remix-run/node' 2 | import { json } from '@remix-run/node' 3 | import invariant from 'tiny-invariant' 4 | import { getAllInstances, getInstanceInfo } from 'litefs-js' 5 | import { ensureInstance } from 'litefs-js/remix.js' 6 | import { cache } from '~/utils/cache.server.ts' 7 | import { requireAdmin } from '~/utils/permissions.server.ts' 8 | 9 | export async function loader({ request, params }: DataFunctionArgs) { 10 | await requireAdmin(request) 11 | const searchParams = new URL(request.url).searchParams 12 | const currentInstanceInfo = await getInstanceInfo() 13 | const allInstances = await getAllInstances() 14 | const instance = 15 | searchParams.get('instance') ?? currentInstanceInfo.currentInstance 16 | await ensureInstance(instance) 17 | 18 | const { cacheKey } = params 19 | invariant(cacheKey, 'cacheKey is required') 20 | return json({ 21 | instance: { 22 | hostname: instance, 23 | region: allInstances[instance], 24 | isPrimary: currentInstanceInfo.primaryInstance === instance, 25 | }, 26 | cacheKey, 27 | value: cache.get(cacheKey), 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /app/routes/admin+/cache_.sqlite.tsx: -------------------------------------------------------------------------------- 1 | import type { DataFunctionArgs } from '@remix-run/node' 2 | import { json, redirect } from '@remix-run/node' 3 | import { z } from 'zod' 4 | import { getInstanceInfo, getInternalInstanceDomain } from 'litefs-js' 5 | import { cache } from '~/utils/cache.server.ts' 6 | 7 | export async function action({ request }: DataFunctionArgs) { 8 | const { currentIsPrimary, primaryInstance } = await getInstanceInfo() 9 | if (!currentIsPrimary) { 10 | throw new Error( 11 | `${request.url} should only be called on the primary instance (${primaryInstance})}`, 12 | ) 13 | } 14 | const token = process.env.INTERNAL_COMMAND_TOKEN 15 | const isAuthorized = 16 | request.headers.get('Authorization') === `Bearer ${token}` 17 | if (!isAuthorized) { 18 | // rick roll them 19 | return redirect('https://www.youtube.com/watch?v=dQw4w9WgXcQ') 20 | } 21 | const { key, cacheValue } = z 22 | .object({ key: z.string(), cacheValue: z.unknown().optional() }) 23 | .parse(await request.json()) 24 | if (cacheValue === undefined) { 25 | await cache.delete(key) 26 | } else { 27 | // @ts-expect-error - we don't reliably know the type of cacheValue 28 | await cache.set(key, cacheValue) 29 | } 30 | return json({ success: true }) 31 | } 32 | 33 | export async function updatePrimaryCacheValue({ 34 | key, 35 | cacheValue, 36 | }: { 37 | key: string 38 | cacheValue: any 39 | }) { 40 | const { currentIsPrimary, primaryInstance } = await getInstanceInfo() 41 | if (currentIsPrimary) { 42 | throw new Error( 43 | `updatePrimaryCacheValue should not be called on the primary instance (${primaryInstance})}`, 44 | ) 45 | } 46 | const domain = getInternalInstanceDomain(primaryInstance) 47 | const token = process.env.INTERNAL_COMMAND_TOKEN 48 | return fetch(`${domain}/admin/cache/sqlite`, { 49 | method: 'POST', 50 | headers: { 51 | Authorization: `Bearer ${token}`, 52 | 'Content-Type': 'application/json', 53 | }, 54 | body: JSON.stringify({ key, cacheValue }), 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /app/routes/me.tsx: -------------------------------------------------------------------------------- 1 | import { redirect, type DataFunctionArgs } from '@remix-run/node' 2 | import { authenticator, requireUserId } from '~/utils/auth.server.ts' 3 | import { prisma } from '~/utils/db.server.ts' 4 | 5 | export async function loader({ request }: DataFunctionArgs) { 6 | const userId = await requireUserId(request) 7 | const user = await prisma.user.findUnique({ where: { id: userId } }) 8 | if (!user) { 9 | const requestUrl = new URL(request.url) 10 | const loginParams = new URLSearchParams([ 11 | ['redirectTo', `${requestUrl.pathname}${requestUrl.search}`], 12 | ]) 13 | const redirectTo = `/login?${loginParams}` 14 | await authenticator.logout(request, { redirectTo }) 15 | return redirect(redirectTo) 16 | } 17 | return redirect(`/users/${user.username}`) 18 | } 19 | -------------------------------------------------------------------------------- /app/routes/resources+/delete-image.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment node 3 | */ 4 | import { faker } from '@faker-js/faker' 5 | import fs from 'fs' 6 | import { createPassword, createUser } from 'tests/db-utils.ts' 7 | import { BASE_URL, getSessionSetCookieHeader } from 'tests/vitest-utils.ts' 8 | import invariant from 'tiny-invariant' 9 | import { expect, test } from 'vitest' 10 | import { prisma } from '~/utils/db.server.ts' 11 | import { ROUTE_PATH, action } from './delete-image.tsx' 12 | 13 | const RESOURCE_URL = `${BASE_URL}${ROUTE_PATH}` 14 | 15 | async function setupUser() { 16 | const userData = createUser() 17 | const session = await prisma.session.create({ 18 | data: { 19 | expirationDate: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), 20 | user: { 21 | create: { 22 | ...userData, 23 | password: { 24 | create: createPassword(userData.username), 25 | }, 26 | image: { 27 | create: { 28 | contentType: 'image/jpeg', 29 | file: { 30 | create: { 31 | blob: await fs.promises.readFile( 32 | './tests/fixtures/test-profile.jpg', 33 | ), 34 | }, 35 | }, 36 | }, 37 | }, 38 | }, 39 | }, 40 | }, 41 | select: { 42 | id: true, 43 | user: { 44 | select: { id: true, imageId: true }, 45 | }, 46 | }, 47 | }) 48 | const { user } = session 49 | invariant(user.imageId, 'User should have an image') 50 | return { 51 | user: { ...user, imageId: user.imageId }, 52 | cookie: await getSessionSetCookieHeader(session), 53 | } 54 | } 55 | 56 | test('allows users to delete their own images', async () => { 57 | const { user, cookie } = await setupUser() 58 | const form = new FormData() 59 | form.set('intent', 'submit') 60 | form.set('imageId', user.imageId) 61 | const request = new Request(RESOURCE_URL, { 62 | method: 'POST', 63 | headers: { cookie }, 64 | body: form, 65 | }) 66 | 67 | const response = await action({ request, params: {}, context: {} }) 68 | expect(await response.json()).toEqual({ status: 'success' }) 69 | const deletedImage = await prisma.image.findUnique({ 70 | where: { fileId: user.imageId }, 71 | }) 72 | 73 | expect(deletedImage, 'Image should be deleted').toBeNull() 74 | }) 75 | 76 | test('requires auth', async () => { 77 | const form = new FormData() 78 | form.set('intent', 'submit') 79 | form.set('imageId', faker.string.uuid()) 80 | const request = new Request(RESOURCE_URL, { 81 | method: 'POST', 82 | body: form, 83 | }) 84 | const response = await action({ request, params: {}, context: {} }).catch( 85 | r => r, 86 | ) 87 | if (!(response instanceof Response)) { 88 | throw new Error('Expected response to be a Response') 89 | } 90 | expect(response.headers.get('Location')).toEqual('/login') 91 | }) 92 | 93 | test('validates the form', async () => { 94 | const { user, cookie } = await setupUser() 95 | const form = new FormData() 96 | form.set('intent', 'submit') 97 | form.set('somethingElse', user.imageId) 98 | const request = new Request(RESOURCE_URL, { 99 | method: 'POST', 100 | headers: { cookie }, 101 | body: form, 102 | }) 103 | const response = await action({ request, params: {}, context: {} }) 104 | expect(await response.json()).toEqual({ 105 | status: 'error', 106 | submission: { 107 | error: { 108 | imageId: 'Required', 109 | }, 110 | intent: 'submit', 111 | payload: { 112 | intent: 'submit', 113 | somethingElse: user.imageId, 114 | }, 115 | }, 116 | }) 117 | expect(response.status).toBe(400) 118 | }) 119 | 120 | test('cannot delete an image that does not exist', async () => { 121 | const { cookie } = await setupUser() 122 | const form = new FormData() 123 | form.set('intent', 'submit') 124 | const fakeImageId = faker.string.uuid() 125 | form.set('imageId', fakeImageId) 126 | const request = new Request(RESOURCE_URL, { 127 | method: 'POST', 128 | headers: { cookie }, 129 | body: form, 130 | }) 131 | const response = await action({ request, params: {}, context: {} }) 132 | expect(await response.json()).toEqual({ 133 | status: 'error', 134 | submission: { 135 | error: { 136 | imageId: ['Image not found'], 137 | }, 138 | intent: 'submit', 139 | payload: { 140 | intent: 'submit', 141 | imageId: fakeImageId, 142 | }, 143 | value: { 144 | imageId: fakeImageId, 145 | }, 146 | }, 147 | }) 148 | expect(response.status).toBe(404) 149 | }) 150 | -------------------------------------------------------------------------------- /app/routes/resources+/delete-image.tsx: -------------------------------------------------------------------------------- 1 | import { parse } from '@conform-to/zod' 2 | import { json, type DataFunctionArgs } from '@remix-run/node' 3 | import { z } from 'zod' 4 | import { requireUserId } from '~/utils/auth.server.ts' 5 | import { prisma } from '~/utils/db.server.ts' 6 | 7 | export const ROUTE_PATH = '/resources/delete-image' 8 | 9 | const DeleteFormSchema = z.object({ 10 | imageId: z.string(), 11 | }) 12 | 13 | export async function action({ request }: DataFunctionArgs) { 14 | const userId = await requireUserId(request, { redirectTo: null }) 15 | const formData = await request.formData() 16 | const submission = parse(formData, { 17 | schema: DeleteFormSchema, 18 | acceptMultipleErrors: () => true, 19 | }) 20 | if (!submission.value) { 21 | return json( 22 | { 23 | status: 'error', 24 | submission, 25 | } as const, 26 | { status: 400 }, 27 | ) 28 | } 29 | if (submission.intent !== 'submit') { 30 | return json({ status: 'success', submission } as const) 31 | } 32 | const { imageId } = submission.value 33 | const image = await prisma.image.findFirst({ 34 | select: { fileId: true }, 35 | where: { 36 | fileId: imageId, 37 | user: { id: userId }, 38 | }, 39 | }) 40 | if (!image) { 41 | submission.error.imageId = ['Image not found'] 42 | return json( 43 | { 44 | status: 'error', 45 | submission, 46 | } as const, 47 | { status: 404 }, 48 | ) 49 | } 50 | 51 | await prisma.image.delete({ 52 | where: { fileId: image.fileId }, 53 | }) 54 | 55 | return json({ status: 'success' } as const) 56 | } 57 | -------------------------------------------------------------------------------- /app/routes/resources+/delete-note.tsx: -------------------------------------------------------------------------------- 1 | import { json, type DataFunctionArgs, redirect } from '@remix-run/node' 2 | import { useFetcher } from '@remix-run/react' 3 | import { Button, ErrorList } from '~/utils/forms.tsx' 4 | import { useForm } from '@conform-to/react' 5 | import { getFieldsetConstraint, parse } from '@conform-to/zod' 6 | import { z } from 'zod' 7 | import { requireUserId } from '~/utils/auth.server.ts' 8 | import { prisma } from '~/utils/db.server.ts' 9 | 10 | const DeleteFormSchema = z.object({ 11 | noteId: z.string(), 12 | }) 13 | 14 | export async function action({ request }: DataFunctionArgs) { 15 | const userId = await requireUserId(request) 16 | const formData = await request.formData() 17 | const submission = parse(formData, { 18 | schema: DeleteFormSchema, 19 | acceptMultipleErrors: () => true, 20 | }) 21 | if (!submission.value || submission.intent !== 'submit') { 22 | return json( 23 | { 24 | status: 'error', 25 | submission, 26 | } as const, 27 | { status: 400 }, 28 | ) 29 | } 30 | 31 | const { noteId } = submission.value 32 | 33 | const note = await prisma.note.findFirst({ 34 | select: { id: true, owner: { select: { username: true } } }, 35 | where: { 36 | id: noteId, 37 | ownerId: userId, 38 | }, 39 | }) 40 | if (!note) { 41 | submission.error.noteId = ['Note not found'] 42 | return json({ status: 'error', submission } as const, { 43 | status: 404, 44 | }) 45 | } 46 | 47 | await prisma.note.delete({ 48 | where: { id: note.id }, 49 | }) 50 | 51 | return redirect(`/users/${note.owner.username}/notes`) 52 | } 53 | 54 | export function DeleteNote({ id }: { id: string }) { 55 | const noteDeleteFetcher = useFetcher() 56 | 57 | const [form] = useForm({ 58 | id: 'delete-note', 59 | constraint: getFieldsetConstraint(DeleteFormSchema), 60 | onValidate({ formData }) { 61 | return parse(formData, { schema: DeleteFormSchema }) 62 | }, 63 | }) 64 | 65 | return ( 66 | 71 | 72 | 85 | 86 | 87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /app/routes/resources+/file.$fileId.tsx: -------------------------------------------------------------------------------- 1 | import { type DataFunctionArgs } from '@remix-run/node' 2 | import { prisma } from '~/utils/db.server.ts' 3 | 4 | export async function loader({ params }: DataFunctionArgs) { 5 | const image = await prisma.image.findUnique({ 6 | where: { fileId: params.fileId }, 7 | select: { contentType: true, file: { select: { blob: true } } }, 8 | }) 9 | 10 | if (!image) throw new Response('Not found', { status: 404 }) 11 | 12 | return new Response(image.file.blob, { 13 | headers: { 14 | 'Content-Type': image.contentType, 15 | 'Cache-Control': 'max-age=31536000', 16 | }, 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /app/routes/resources+/healthcheck.tsx: -------------------------------------------------------------------------------- 1 | // learn more: https://fly.io/docs/reference/configuration/#services-http_checks 2 | import { type DataFunctionArgs } from '@remix-run/node' 3 | 4 | import { prisma } from '~/utils/db.server.ts' 5 | 6 | export async function loader({ request }: DataFunctionArgs) { 7 | const host = 8 | request.headers.get('X-Forwarded-Host') ?? request.headers.get('host') 9 | 10 | try { 11 | const url = new URL('/', `http://${host}`) 12 | // if we can connect to the database and make a simple query 13 | // and make a HEAD request to ourselves, then we're good. 14 | await Promise.all([ 15 | prisma.user.count(), 16 | fetch(url.toString(), { method: 'HEAD' }).then(r => { 17 | if (!r.ok) return Promise.reject(r) 18 | }), 19 | ]) 20 | return new Response('OK') 21 | } catch (error: unknown) { 22 | console.error('healthcheck ❌', { error }) 23 | return new Response('ERROR', { status: 500 }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/routes/resources+/image-upload.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | json, 3 | unstable_createMemoryUploadHandler, 4 | unstable_parseMultipartFormData, 5 | type DataFunctionArgs, 6 | } from '@remix-run/node' 7 | import { useFetcher } from '@remix-run/react' 8 | import invariant from 'tiny-invariant' 9 | import { prisma } from '~/utils/db.server.ts' 10 | 11 | const MAX_SIZE = 1024 * 1024 * 5 // 5MB 12 | 13 | export async function action({ request }: DataFunctionArgs) { 14 | const contentLength = Number(request.headers.get('Content-Length')) 15 | if ( 16 | contentLength && 17 | Number.isFinite(contentLength) && 18 | contentLength > MAX_SIZE 19 | ) { 20 | return json({ errors: 'File too large' }, { status: 400 }) 21 | } 22 | const formData = await unstable_parseMultipartFormData( 23 | request, 24 | unstable_createMemoryUploadHandler({ maxPartSize: MAX_SIZE }), 25 | ) 26 | 27 | const file = formData.get('file') 28 | invariant(file instanceof File, 'file not the right type') 29 | const altText = formData.get('altText') 30 | const image = await prisma.image.create({ 31 | select: { fileId: true }, 32 | data: { 33 | contentType: file.type, 34 | altText: typeof altText === 'string' ? altText : undefined, 35 | file: { 36 | create: { 37 | blob: Buffer.from(await file.arrayBuffer()), 38 | }, 39 | }, 40 | }, 41 | }) 42 | 43 | return json({ fileId: image.fileId }) 44 | } 45 | 46 | export function ImageUpload() { 47 | const fetcher = useFetcher() 48 | 49 | return ( 50 | 55 | 56 | 57 | 58 | 59 | 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /app/routes/resources+/note-editor.tsx: -------------------------------------------------------------------------------- 1 | import { conform, useForm } from '@conform-to/react' 2 | import { getFieldsetConstraint, parse } from '@conform-to/zod' 3 | import { json, redirect, type DataFunctionArgs } from '@remix-run/node' 4 | import { useFetcher } from '@remix-run/react' 5 | import { z } from 'zod' 6 | import { requireUserId } from '~/utils/auth.server.ts' 7 | import { prisma } from '~/utils/db.server.ts' 8 | import { Button, ErrorList, Field, TextareaField } from '~/utils/forms.tsx' 9 | 10 | export const NoteEditorSchema = z.object({ 11 | id: z.string().optional(), 12 | title: z.string().min(1), 13 | content: z.string().min(1), 14 | }) 15 | 16 | export async function action({ request }: DataFunctionArgs) { 17 | const userId = await requireUserId(request) 18 | const formData = await request.formData() 19 | const submission = parse(formData, { 20 | schema: NoteEditorSchema, 21 | acceptMultipleErrors: () => true, 22 | }) 23 | if (submission.intent !== 'submit') { 24 | return json({ status: 'idle', submission } as const) 25 | } 26 | if (!submission.value) { 27 | return json( 28 | { 29 | status: 'error', 30 | submission, 31 | } as const, 32 | { status: 400 }, 33 | ) 34 | } 35 | let note: { id: string; owner: { username: string } } 36 | 37 | const { title, content, id } = submission.value 38 | 39 | const data = { 40 | ownerId: userId, 41 | title: title, 42 | content: content, 43 | } 44 | 45 | const select = { 46 | id: true, 47 | owner: { 48 | select: { 49 | username: true, 50 | }, 51 | }, 52 | } 53 | if (id) { 54 | const existingNote = await prisma.note.findFirst({ 55 | where: { id, ownerId: userId }, 56 | select: { id: true }, 57 | }) 58 | if (!existingNote) { 59 | return json( 60 | { 61 | status: 'error', 62 | submission, 63 | } as const, 64 | { status: 404 }, 65 | ) 66 | } 67 | note = await prisma.note.update({ 68 | where: { id }, 69 | data, 70 | select, 71 | }) 72 | } else { 73 | note = await prisma.note.create({ data, select }) 74 | } 75 | return redirect(`/users/${note.owner.username}/notes/${note.id}`) 76 | } 77 | 78 | export function NoteEditor({ 79 | note, 80 | }: { 81 | note?: { id: string; title: string; content: string } 82 | }) { 83 | const noteEditorFetcher = useFetcher() 84 | 85 | const [form, fields] = useForm({ 86 | id: 'note-editor', 87 | constraint: getFieldsetConstraint(NoteEditorSchema), 88 | lastSubmission: noteEditorFetcher.data?.submission, 89 | onValidate({ formData }) { 90 | return parse(formData, { schema: NoteEditorSchema }) 91 | }, 92 | defaultValue: { 93 | title: note?.title, 94 | content: note?.content, 95 | }, 96 | shouldRevalidate: 'onBlur', 97 | }) 98 | 99 | return ( 100 | 105 | 106 | 114 | 122 | 123 |
124 | 127 | 140 |
141 |
142 | ) 143 | } 144 | -------------------------------------------------------------------------------- /app/routes/resources+/theme/index.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from '@conform-to/react' 2 | import { parse } from '@conform-to/zod' 3 | import { json, redirect, type DataFunctionArgs } from '@remix-run/node' 4 | import { useFetcher } from '@remix-run/react' 5 | import * as React from 'react' 6 | import { z } from 'zod' 7 | import { useHints } from '~/utils/client-hints.tsx' 8 | import { ErrorList } from '~/utils/forms.tsx' 9 | import { safeRedirect } from '~/utils/misc.ts' 10 | import { useRequestInfo } from '~/utils/request-info.ts' 11 | import { 12 | commitSession, 13 | deleteTheme, 14 | getSession, 15 | setTheme, 16 | } from './theme-session.server.ts' 17 | 18 | const ROUTE_PATH = '/resources/theme' 19 | 20 | const ThemeFormSchema = z.object({ 21 | redirectTo: z.string().optional(), 22 | theme: z.enum(['system', 'light', 'dark']), 23 | }) 24 | 25 | export async function action({ request }: DataFunctionArgs) { 26 | const formData = await request.formData() 27 | const submission = parse(formData, { 28 | schema: ThemeFormSchema, 29 | acceptMultipleErrors: () => true, 30 | }) 31 | if (!submission.value) { 32 | return json( 33 | { 34 | status: 'error', 35 | submission, 36 | } as const, 37 | { status: 400 }, 38 | ) 39 | } 40 | if (submission.intent !== 'submit') { 41 | return json({ status: 'success', submission } as const) 42 | } 43 | const session = await getSession(request.headers.get('cookie')) 44 | const { redirectTo, theme } = submission.value 45 | if (theme === 'system') { 46 | deleteTheme(session) 47 | } else { 48 | setTheme(session, theme) 49 | } 50 | 51 | const responseInit = { 52 | headers: { 'Set-Cookie': await commitSession(session) }, 53 | } 54 | if (redirectTo) { 55 | return redirect(safeRedirect(redirectTo), responseInit) 56 | } else { 57 | return json({ success: true }, responseInit) 58 | } 59 | } 60 | 61 | export function ThemeSwitch({ 62 | userPreference, 63 | }: { 64 | userPreference: 'light' | 'dark' | null 65 | }) { 66 | const requestInfo = useRequestInfo() 67 | const fetcher = useFetcher() 68 | const [isHydrated, setIsHydrated] = React.useState(false) 69 | 70 | React.useEffect(() => { 71 | setIsHydrated(true) 72 | }, []) 73 | 74 | const [form] = useForm({ 75 | id: 'onboarding', 76 | lastSubmission: fetcher.data?.submission, 77 | onValidate({ formData }) { 78 | return parse(formData, { schema: ThemeFormSchema }) 79 | }, 80 | }) 81 | 82 | const mode = userPreference ?? 'system' 83 | const nextMode = 84 | mode === 'system' ? 'light' : mode === 'light' ? 'dark' : 'system' 85 | const modeLabel = { 86 | light: ( 87 | <> 88 | 🔆 Light 89 | 90 | ), 91 | dark: ( 92 | <> 93 | 🌕 Dark 94 | 95 | ), 96 | system: ( 97 | <> 98 | 💻 System 99 | 100 | ), 101 | } 102 | 103 | return ( 104 | 105 |
106 | {/* 107 | this is for progressive enhancement so we redirect them to the page 108 | they are on if the JavaScript hasn't had a chance to hydrate yet. 109 | */} 110 | {isHydrated ? null : ( 111 | 112 | )} 113 | 114 | 117 |
118 | 119 |
120 | ) 121 | } 122 | 123 | /** 124 | * @returns the user's theme preference, or the client hint theme if the user 125 | * has not set a preference. 126 | */ 127 | export function useTheme() { 128 | const hints = useHints() 129 | const requestInfo = useRequestInfo() 130 | return requestInfo.session.theme ?? hints.theme 131 | } 132 | -------------------------------------------------------------------------------- /app/routes/resources+/theme/theme-session.server.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage } from '@remix-run/node' 2 | 3 | export const sessionStorage = createCookieSessionStorage({ 4 | cookie: { 5 | name: 'theme', 6 | sameSite: 'lax', 7 | path: '/', 8 | httpOnly: true, 9 | secrets: [process.env.SESSION_SECRET], 10 | secure: process.env.NODE_ENV === 'production', 11 | }, 12 | }) 13 | 14 | export const { getSession, commitSession, destroySession } = sessionStorage 15 | 16 | type Session = Awaited> 17 | 18 | const themeKey = 'theme' 19 | 20 | export async function getTheme( 21 | request: Request, 22 | ): Promise<'dark' | 'light' | null> { 23 | const session = await getSession(request.headers.get('Cookie')) 24 | const theme = session.get(themeKey) 25 | if (theme === 'dark' || theme === 'light') return theme 26 | return null 27 | } 28 | 29 | export function setTheme(session: Session, theme: 'dark' | 'light') { 30 | session.set(themeKey, theme) 31 | } 32 | 33 | export function deleteTheme(session: Session) { 34 | session.unset(themeKey) 35 | } 36 | -------------------------------------------------------------------------------- /app/routes/settings+/profile.two-factor.index.tsx: -------------------------------------------------------------------------------- 1 | import { json, redirect, type DataFunctionArgs } from '@remix-run/node' 2 | import { useFetcher, useLoaderData } from '@remix-run/react' 3 | import { requireUserId } from '~/utils/auth.server.ts' 4 | import { prisma } from '~/utils/db.server.ts' 5 | import { Button } from '~/utils/forms.tsx' 6 | import { generateTOTP } from '~/utils/totp.server.ts' 7 | import { verificationType as verifyVerificationType } from './profile.two-factor.verify.tsx' 8 | import { twoFAVerificationType } from './profile.two-factor.tsx' 9 | 10 | export async function loader({ request }: DataFunctionArgs) { 11 | const userId = await requireUserId(request) 12 | const verification = await prisma.verification.findFirst({ 13 | where: { type: twoFAVerificationType, target: userId }, 14 | select: { id: true }, 15 | }) 16 | return json({ is2FAEnabled: Boolean(verification) }) 17 | } 18 | 19 | export async function action({ request }: DataFunctionArgs) { 20 | const form = await request.formData() 21 | const userId = await requireUserId(request) 22 | const intent = form.get('intent') 23 | switch (intent) { 24 | case 'enable': { 25 | const { otp: _otp, ...config } = generateTOTP() 26 | // delete any existing entries 27 | await prisma.verification.deleteMany({ 28 | where: { type: verifyVerificationType, target: userId }, 29 | }) 30 | await prisma.verification.create({ 31 | data: { ...config, type: verifyVerificationType, target: userId }, 32 | }) 33 | return redirect('/settings/profile/two-factor/verify') 34 | } 35 | case 'disable': { 36 | await prisma.verification.deleteMany({ 37 | where: { type: twoFAVerificationType, target: userId }, 38 | }) 39 | break 40 | } 41 | default: { 42 | return json({ status: 'error', message: 'Invalid intent' } as const) 43 | } 44 | } 45 | return json({ status: 'success' } as const) 46 | } 47 | 48 | export default function TwoFactorRoute() { 49 | const data = useLoaderData() 50 | const toggle2FAFetcher = useFetcher() 51 | 52 | return ( 53 |
54 | {data.is2FAEnabled ? ( 55 | <> 56 |

You have enabled two-factor authentication.

57 | 58 | 69 | 70 | 71 | ) : ( 72 | <> 73 |

You have not enabled two-factor authentication yet.

74 |

75 | Two factor authentication adds an extra layer of security to your 76 | account. You will need to enter a code from an authenticator app 77 | like 1Password to log in. 78 |

79 | 80 | 91 | 92 | 93 | )} 94 |
95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /app/routes/settings+/profile.two-factor.tsx: -------------------------------------------------------------------------------- 1 | import * as Dialog from '@radix-ui/react-dialog' 2 | import { json, type DataFunctionArgs } from '@remix-run/node' 3 | import { Link, Outlet, useNavigate } from '@remix-run/react' 4 | import { requireUserId } from '~/utils/auth.server.ts' 5 | import { prisma } from '~/utils/db.server.ts' 6 | 7 | export const twoFAVerificationType = '2fa' 8 | 9 | export async function loader({ request }: DataFunctionArgs) { 10 | const userId = await requireUserId(request) 11 | const verification = await prisma.verification.findFirst({ 12 | where: { type: twoFAVerificationType, target: userId }, 13 | select: { id: true }, 14 | }) 15 | return json({ is2FAEnabled: Boolean(verification) }) 16 | } 17 | 18 | export default function TwoFactorRoute() { 19 | const navigate = useNavigate() 20 | 21 | const dismissModal = () => navigate('..', { preventScrollReset: true }) 22 | return ( 23 | 24 | 25 | 26 | 31 | 32 |

Two-Factor Authentication

33 |
34 |
35 | 36 |
37 | 38 | 44 | ❌ 45 | 46 | 47 |
48 |
49 |
50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /app/routes/users+/$username.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | json, 3 | type DataFunctionArgs, 4 | type V2_MetaFunction, 5 | } from '@remix-run/node' 6 | import { useLoaderData } from '@remix-run/react' 7 | import invariant from 'tiny-invariant' 8 | import { GeneralErrorBoundary } from '~/components/error-boundary.tsx' 9 | import { Spacer } from '~/components/spacer.tsx' 10 | import { prisma } from '~/utils/db.server.ts' 11 | import { ButtonLink } from '~/utils/forms.tsx' 12 | import { getUserImgSrc } from '~/utils/misc.ts' 13 | import { useOptionalUser } from '~/utils/user.ts' 14 | 15 | export async function loader({ params }: DataFunctionArgs) { 16 | invariant(params.username, 'Missing username') 17 | const user = await prisma.user.findUnique({ 18 | where: { username: params.username }, 19 | select: { 20 | id: true, 21 | username: true, 22 | name: true, 23 | imageId: true, 24 | createdAt: true, 25 | }, 26 | }) 27 | if (!user) { 28 | throw new Response('not found', { status: 404 }) 29 | } 30 | return json({ user, userJoinedDisplay: user.createdAt.toLocaleDateString() }) 31 | } 32 | 33 | export default function UsernameIndex() { 34 | const data = useLoaderData() 35 | const user = data.user 36 | const userDisplayName = user.name ?? user.username 37 | const loggedInUser = useOptionalUser() 38 | const isLoggedInUser = data.user.id === loggedInUser?.id 39 | 40 | return ( 41 |
42 | 43 | 44 |
45 |
46 |
47 |
48 | {userDisplayName} 53 |
54 |
55 |
56 | 57 | 58 | 59 |
60 |
61 |

{userDisplayName}

62 |
63 |

64 | Joined {data.userJoinedDisplay} 65 |

66 |
67 | {isLoggedInUser ? ( 68 | <> 69 | 75 | My notes 76 | 77 | 83 | Edit profile 84 | 85 | 86 | ) : ( 87 | 93 | {userDisplayName}'s notes 94 | 95 | )} 96 |
97 |
98 |
99 |
100 | ) 101 | } 102 | 103 | export function ErrorBoundary() { 104 | return ( 105 | ( 108 |

No user with the username "{params.username}" exists

109 | ), 110 | }} 111 | /> 112 | ) 113 | } 114 | 115 | export const meta: V2_MetaFunction = ({ data, params }) => { 116 | const displayName = data?.user.name ?? params.username 117 | return [ 118 | { title: `${displayName} | Epic Notes` }, 119 | { 120 | name: 'description', 121 | content: `Profile of ${displayName} on Epic Notes`, 122 | }, 123 | ] 124 | } 125 | -------------------------------------------------------------------------------- /app/routes/users+/$username_+/notes.$noteId.tsx: -------------------------------------------------------------------------------- 1 | import { json, type DataFunctionArgs } from '@remix-run/node' 2 | import { useLoaderData } from '@remix-run/react' 3 | import { GeneralErrorBoundary } from '~/components/error-boundary.tsx' 4 | import { DeleteNote } from '~/routes/resources+/delete-note.tsx' 5 | import { getUserId } from '~/utils/auth.server.ts' 6 | import { prisma } from '~/utils/db.server.ts' 7 | import { ButtonLink } from '~/utils/forms.tsx' 8 | 9 | export async function loader({ request, params }: DataFunctionArgs) { 10 | const userId = await getUserId(request) 11 | const note = await prisma.note.findUnique({ 12 | where: { 13 | id: params.noteId, 14 | }, 15 | select: { 16 | id: true, 17 | title: true, 18 | content: true, 19 | ownerId: true, 20 | }, 21 | }) 22 | if (!note) { 23 | throw new Response('Not found', { status: 404 }) 24 | } 25 | return json({ note, isOwner: userId === note.ownerId }) 26 | } 27 | 28 | export default function NoteRoute() { 29 | const data = useLoaderData() 30 | 31 | return ( 32 |
33 |
34 |

{data.note.title}

35 |

{data.note.content}

36 |
37 | {data.isOwner ? ( 38 |
39 | 40 | 41 | Edit 42 | 43 |
44 | ) : null} 45 |
46 | ) 47 | } 48 | 49 | export function ErrorBoundary() { 50 | return ( 51 |

Note not found

, 54 | }} 55 | /> 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /app/routes/users+/$username_+/notes.$noteId_.edit.tsx: -------------------------------------------------------------------------------- 1 | import { json, type DataFunctionArgs } from '@remix-run/node' 2 | import { useLoaderData } from '@remix-run/react' 3 | import { NoteEditor } from '~/routes/resources+/note-editor.tsx' 4 | import { requireUserId } from '~/utils/auth.server.ts' 5 | import { prisma } from '~/utils/db.server.ts' 6 | 7 | export async function loader({ params, request }: DataFunctionArgs) { 8 | const userId = await requireUserId(request) 9 | const note = await prisma.note.findFirst({ 10 | where: { 11 | id: params.noteId, 12 | ownerId: userId, 13 | }, 14 | }) 15 | if (!note) { 16 | throw new Response('Not found', { status: 404 }) 17 | } 18 | return json({ note: note }) 19 | } 20 | 21 | export default function NoteEdit() { 22 | const data = useLoaderData() 23 | 24 | return 25 | } 26 | -------------------------------------------------------------------------------- /app/routes/users+/$username_+/notes.index.tsx: -------------------------------------------------------------------------------- 1 | export default function NotesIndexRoute() { 2 | return

Select a note

3 | } 4 | -------------------------------------------------------------------------------- /app/routes/users+/$username_+/notes.new.tsx: -------------------------------------------------------------------------------- 1 | import { json } from '@remix-run/router' 2 | import { type DataFunctionArgs } from '@remix-run/server-runtime' 3 | import { NoteEditor } from '~/routes/resources+/note-editor.tsx' 4 | import { requireUserId } from '~/utils/auth.server.ts' 5 | 6 | export async function loader({ request }: DataFunctionArgs) { 7 | await requireUserId(request) 8 | return json({}) 9 | } 10 | 11 | export default function NewNoteRoute() { 12 | return 13 | } 14 | -------------------------------------------------------------------------------- /app/routes/users+/$username_+/notes.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | json, 3 | type DataFunctionArgs, 4 | type HeadersFunction, 5 | } from '@remix-run/node' 6 | import { Link, NavLink, Outlet, useLoaderData } from '@remix-run/react' 7 | import { twMerge } from 'tailwind-merge' 8 | import { GeneralErrorBoundary } from '~/components/error-boundary.tsx' 9 | import { prisma } from '~/utils/db.server.ts' 10 | import { getUserImgSrc } from '~/utils/misc.ts' 11 | import { 12 | combineServerTimings, 13 | makeTimings, 14 | time, 15 | } from '~/utils/timing.server.ts' 16 | 17 | export async function loader({ params }: DataFunctionArgs) { 18 | const timings = makeTimings('notes loader') 19 | const owner = await time( 20 | () => 21 | prisma.user.findUnique({ 22 | where: { 23 | username: params.username, 24 | }, 25 | select: { 26 | id: true, 27 | username: true, 28 | name: true, 29 | imageId: true, 30 | }, 31 | }), 32 | { timings, type: 'find user' }, 33 | ) 34 | if (!owner) { 35 | throw new Response('Not found', { status: 404 }) 36 | } 37 | const notes = await time( 38 | () => 39 | prisma.note.findMany({ 40 | where: { 41 | ownerId: owner.id, 42 | }, 43 | select: { 44 | id: true, 45 | title: true, 46 | }, 47 | }), 48 | { timings, type: 'find notes' }, 49 | ) 50 | return json( 51 | { owner, notes }, 52 | { headers: { 'Server-Timing': timings.toString() } }, 53 | ) 54 | } 55 | 56 | export const headers: HeadersFunction = ({ loaderHeaders, parentHeaders }) => { 57 | return { 58 | 'Server-Timing': combineServerTimings(parentHeaders, loaderHeaders), 59 | } 60 | } 61 | 62 | export default function NotesRoute() { 63 | const data = useLoaderData() 64 | const ownerDisplayName = data.owner.name ?? data.owner.username 65 | const navLinkDefaultClassName = 66 | 'line-clamp-2 block rounded-l-full py-2 pl-8 pr-6 text-base lg:text-xl' 67 | return ( 68 |
69 |
70 |
71 | 75 | {ownerDisplayName} 80 |

81 | {ownerDisplayName}'s Notes 82 |

83 | 84 |
    85 |
  • 86 | 89 | twMerge(navLinkDefaultClassName, isActive && 'bg-night-400') 90 | } 91 | > 92 | + New Note 93 | 94 |
  • 95 | {data.notes.map(note => ( 96 |
  • 97 | 100 | twMerge(navLinkDefaultClassName, isActive && 'bg-night-400') 101 | } 102 | > 103 | {note.title} 104 | 105 |
  • 106 | ))} 107 |
108 |
109 |
110 | 111 |
112 |
113 |
114 | ) 115 | } 116 | 117 | export function ErrorBoundary() { 118 | return ( 119 | ( 122 |

No user with the username "{params.username}" exists

123 | ), 124 | }} 125 | /> 126 | ) 127 | } 128 | -------------------------------------------------------------------------------- /app/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /app/utils/auth.server.ts: -------------------------------------------------------------------------------- 1 | import { type Password, type User } from '@prisma/client' 2 | import bcrypt from 'bcryptjs' 3 | import { Authenticator } from 'remix-auth' 4 | import { FormStrategy } from 'remix-auth-form' 5 | import invariant from 'tiny-invariant' 6 | import { prisma } from '~/utils/db.server.ts' 7 | import { sessionStorage } from './session.server.ts' 8 | import { redirect } from '@remix-run/node' 9 | 10 | export type { User } 11 | 12 | export const authenticator = new Authenticator(sessionStorage, { 13 | sessionKey: 'sessionId', 14 | }) 15 | 16 | const SESSION_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 30 17 | 18 | authenticator.use( 19 | new FormStrategy(async ({ form }) => { 20 | const username = form.get('username') 21 | const password = form.get('password') 22 | 23 | invariant(typeof username === 'string', 'username must be a string') 24 | invariant(username.length > 0, 'username must not be empty') 25 | 26 | invariant(typeof password === 'string', 'password must be a string') 27 | invariant(password.length > 0, 'password must not be empty') 28 | 29 | const user = await verifyLogin(username, password) 30 | if (!user) { 31 | throw new Error('Invalid username or password') 32 | } 33 | const session = await prisma.session.create({ 34 | data: { 35 | expirationDate: new Date(Date.now() + SESSION_EXPIRATION_TIME), 36 | userId: user.id, 37 | }, 38 | select: { id: true }, 39 | }) 40 | 41 | return session.id 42 | }), 43 | FormStrategy.name, 44 | ) 45 | 46 | export async function requireUserId( 47 | request: Request, 48 | { redirectTo }: { redirectTo?: string | null } = {}, 49 | ) { 50 | const requestUrl = new URL(request.url) 51 | redirectTo = 52 | redirectTo === null 53 | ? null 54 | : redirectTo ?? `${requestUrl.pathname}${requestUrl.search}` 55 | const loginParams = redirectTo 56 | ? new URLSearchParams([['redirectTo', redirectTo]]) 57 | : null 58 | const failureRedirect = ['/login', loginParams?.toString()] 59 | .filter(Boolean) 60 | .join('?') 61 | const sessionId = await authenticator.isAuthenticated(request, { 62 | failureRedirect, 63 | }) 64 | const session = await prisma.session.findFirst({ 65 | where: { id: sessionId }, 66 | select: { userId: true, expirationDate: true }, 67 | }) 68 | if (!session) { 69 | throw redirect(failureRedirect) 70 | } 71 | return session.userId 72 | } 73 | 74 | export async function getUserId(request: Request) { 75 | const sessionId = await authenticator.isAuthenticated(request) 76 | if (!sessionId) return null 77 | const session = await prisma.session.findUnique({ 78 | where: { id: sessionId }, 79 | select: { userId: true }, 80 | }) 81 | if (!session) { 82 | // Perhaps their session was deleted? 83 | await authenticator.logout(request, { redirectTo: '/' }) 84 | return null 85 | } 86 | return session.userId 87 | } 88 | 89 | export async function requireAnonymous(request: Request) { 90 | await authenticator.isAuthenticated(request, { 91 | successRedirect: '/', 92 | }) 93 | } 94 | 95 | export async function resetUserPassword({ 96 | username, 97 | password, 98 | }: { 99 | username: User['username'] 100 | password: string 101 | }) { 102 | const hashedPassword = await bcrypt.hash(password, 10) 103 | return prisma.user.update({ 104 | where: { username }, 105 | data: { 106 | password: { 107 | update: { 108 | hash: hashedPassword, 109 | }, 110 | }, 111 | }, 112 | }) 113 | } 114 | 115 | export async function signup({ 116 | email, 117 | username, 118 | password, 119 | name, 120 | }: { 121 | email: User['email'] 122 | username: User['username'] 123 | name: User['name'] 124 | password: string 125 | }) { 126 | const hashedPassword = await getPasswordHash(password) 127 | 128 | const session = await prisma.session.create({ 129 | data: { 130 | expirationDate: new Date(Date.now() + SESSION_EXPIRATION_TIME), 131 | user: { 132 | create: { 133 | email, 134 | username, 135 | name, 136 | password: { 137 | create: { 138 | hash: hashedPassword, 139 | }, 140 | }, 141 | }, 142 | }, 143 | }, 144 | select: { id: true, expirationDate: true }, 145 | }) 146 | return session 147 | } 148 | 149 | export async function getPasswordHash(password: string) { 150 | const hash = await bcrypt.hash(password, 10) 151 | return hash 152 | } 153 | 154 | export async function verifyLogin( 155 | username: User['username'], 156 | password: Password['hash'], 157 | ) { 158 | const userWithPassword = await prisma.user.findUnique({ 159 | where: { username }, 160 | select: { id: true, password: { select: { hash: true } } }, 161 | }) 162 | 163 | if (!userWithPassword || !userWithPassword.password) { 164 | return null 165 | } 166 | 167 | const isValid = await bcrypt.compare(password, userWithPassword.password.hash) 168 | 169 | if (!isValid) { 170 | return null 171 | } 172 | 173 | return { id: userWithPassword.id } 174 | } 175 | -------------------------------------------------------------------------------- /app/utils/cache.server.ts: -------------------------------------------------------------------------------- 1 | import type BetterSqlite3 from 'better-sqlite3' 2 | import Database from 'better-sqlite3' 3 | import { 4 | cachified as baseCachified, 5 | lruCacheAdapter, 6 | verboseReporter, 7 | mergeReporters, 8 | type CacheEntry, 9 | type Cache as CachifiedCache, 10 | type CachifiedOptions, 11 | } from 'cachified' 12 | import fs from 'fs' 13 | import { getInstanceInfo, getInstanceInfoSync } from 'litefs-js' 14 | import { LRUCache } from 'lru-cache' 15 | import { z } from 'zod' 16 | import { updatePrimaryCacheValue } from '~/routes/admin+/cache_.sqlite.tsx' 17 | import { cachifiedTimingReporter, type Timings } from './timing.server.ts' 18 | import { singleton } from './singleton.server.ts' 19 | 20 | const CACHE_DATABASE_PATH = process.env.CACHE_DATABASE_PATH 21 | 22 | const cacheDb = singleton('cacheDb', createDatabase) 23 | 24 | function createDatabase(tryAgain = true): BetterSqlite3.Database { 25 | const db = new Database(CACHE_DATABASE_PATH) 26 | const { currentIsPrimary } = getInstanceInfoSync() 27 | if (!currentIsPrimary) return db 28 | 29 | try { 30 | // create cache table with metadata JSON column and value JSON column if it does not exist already 31 | db.exec(` 32 | CREATE TABLE IF NOT EXISTS cache ( 33 | key TEXT PRIMARY KEY, 34 | metadata TEXT, 35 | value TEXT 36 | ) 37 | `) 38 | } catch (error: unknown) { 39 | fs.unlinkSync(CACHE_DATABASE_PATH) 40 | if (tryAgain) { 41 | console.error( 42 | `Error creating cache database, deleting the file at "${CACHE_DATABASE_PATH}" and trying again...`, 43 | ) 44 | return createDatabase(false) 45 | } 46 | throw error 47 | } 48 | return db 49 | } 50 | 51 | const lru = singleton( 52 | 'lru-cache', 53 | () => new LRUCache>({ max: 5000 }), 54 | ) 55 | 56 | export const lruCache = lruCacheAdapter(lru) 57 | 58 | const cacheEntrySchema = z.object({ 59 | metadata: z.object({ 60 | createdTime: z.number(), 61 | ttl: z.number().nullable().optional(), 62 | swr: z.number().nullable().optional(), 63 | }), 64 | value: z.unknown(), 65 | }) 66 | const cacheQueryResultSchema = z.object({ 67 | metadata: z.string(), 68 | value: z.string(), 69 | }) 70 | 71 | export const cache: CachifiedCache = { 72 | name: 'SQLite cache', 73 | get(key) { 74 | const result = cacheDb 75 | .prepare('SELECT value, metadata FROM cache WHERE key = ?') 76 | .get(key) 77 | const parseResult = cacheQueryResultSchema.safeParse(result) 78 | if (!parseResult.success) return null 79 | 80 | const parsedEntry = cacheEntrySchema.safeParse({ 81 | metadata: JSON.parse(parseResult.data.metadata), 82 | value: JSON.parse(parseResult.data.value), 83 | }) 84 | if (!parsedEntry.success) return null 85 | const { metadata, value } = parsedEntry.data 86 | if (!value) return null 87 | return { metadata, value } 88 | }, 89 | async set(key, entry) { 90 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 91 | const { currentIsPrimary, primaryInstance } = await getInstanceInfo() 92 | if (currentIsPrimary) { 93 | cacheDb 94 | .prepare( 95 | 'INSERT OR REPLACE INTO cache (key, value, metadata) VALUES (@key, @value, @metadata)', 96 | ) 97 | .run({ 98 | key, 99 | value: JSON.stringify(entry.value), 100 | metadata: JSON.stringify(entry.metadata), 101 | }) 102 | } else { 103 | // fire-and-forget cache update 104 | void updatePrimaryCacheValue({ 105 | key, 106 | cacheValue: entry, 107 | }).then(response => { 108 | if (!response.ok) { 109 | console.error( 110 | `Error updating cache value for key "${key}" on primary instance (${primaryInstance}): ${response.status} ${response.statusText}`, 111 | { entry }, 112 | ) 113 | } 114 | }) 115 | } 116 | }, 117 | async delete(key) { 118 | const { currentIsPrimary, primaryInstance } = await getInstanceInfo() 119 | if (currentIsPrimary) { 120 | cacheDb.prepare('DELETE FROM cache WHERE key = ?').run(key) 121 | } else { 122 | // fire-and-forget cache update 123 | void updatePrimaryCacheValue({ 124 | key, 125 | cacheValue: undefined, 126 | }).then(response => { 127 | if (!response.ok) { 128 | console.error( 129 | `Error deleting cache value for key "${key}" on primary instance (${primaryInstance}): ${response.status} ${response.statusText}`, 130 | ) 131 | } 132 | }) 133 | } 134 | }, 135 | } 136 | 137 | export async function getAllCacheKeys(limit: number) { 138 | return { 139 | sqlite: cacheDb 140 | .prepare('SELECT key FROM cache LIMIT ?') 141 | .all(limit) 142 | .map(row => (row as { key: string }).key), 143 | lru: [...lru.keys()], 144 | } 145 | } 146 | 147 | export async function searchCacheKeys(search: string, limit: number) { 148 | return { 149 | sqlite: cacheDb 150 | .prepare('SELECT key FROM cache WHERE key LIKE ? LIMIT ?') 151 | .all(`%${search}%`, limit) 152 | .map(row => (row as { key: string }).key), 153 | lru: [...lru.keys()].filter(key => key.includes(search)), 154 | } 155 | } 156 | 157 | export async function cachified({ 158 | timings, 159 | reporter = verboseReporter(), 160 | ...options 161 | }: CachifiedOptions & { 162 | timings?: Timings 163 | }): Promise { 164 | return baseCachified({ 165 | ...options, 166 | reporter: mergeReporters(cachifiedTimingReporter(timings), reporter), 167 | }) 168 | } 169 | -------------------------------------------------------------------------------- /app/utils/client-hints.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains utilities for using client hints for user preference which 3 | * are needed by the server, but are only known by the browser. 4 | */ 5 | import * as React from 'react' 6 | import { useRequestInfo } from './request-info.ts' 7 | import { useRevalidator } from '@remix-run/react' 8 | 9 | export const clientHints = { 10 | theme: { 11 | cookieName: 'CH-prefers-color-scheme', 12 | getValueCode: `window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'`, 13 | fallback: 'light', 14 | transform(value: string | null) { 15 | return value === 'dark' ? 'dark' : 'light' 16 | }, 17 | }, 18 | // add other hints here 19 | } 20 | 21 | type ClientHintNames = keyof typeof clientHints 22 | 23 | function getCookieValue(cookieString: string, name: ClientHintNames) { 24 | const hint = clientHints[name] 25 | if (!hint) { 26 | throw new Error(`Unknown client hint: ${name}`) 27 | } 28 | const value = cookieString 29 | .split(';') 30 | .map(c => c.trim()) 31 | .find(c => c.startsWith(hint.cookieName + '=')) 32 | ?.split('=')[1] 33 | 34 | return value ?? null 35 | } 36 | 37 | /** 38 | * 39 | * @param request {Request} - optional request object (only used on server) 40 | * @returns an object with the client hints and their values 41 | */ 42 | export function getHints(request?: Request) { 43 | const cookieString = 44 | typeof document !== 'undefined' 45 | ? document.cookie 46 | : typeof request !== 'undefined' 47 | ? request.headers.get('Cookie') ?? '' 48 | : '' 49 | 50 | return Object.entries(clientHints).reduce( 51 | (acc, [name, hint]) => { 52 | const hintName = name as ClientHintNames 53 | // using ignore because it's not an issue with only one hint, but will 54 | // be with more than one... 55 | // @ts-ignore PR to improve these types is welcome 56 | acc[hintName] = hint.transform(getCookieValue(cookieString, hintName)) 57 | return acc 58 | }, 59 | {} as { 60 | [name in ClientHintNames]: ReturnType< 61 | (typeof clientHints)[name]['transform'] 62 | > 63 | }, 64 | ) 65 | } 66 | 67 | /** 68 | * @returns an object with the client hints and their values 69 | */ 70 | export function useHints() { 71 | const requestInfo = useRequestInfo() 72 | return requestInfo.hints 73 | } 74 | 75 | /** 76 | * @returns inline script element that checks for client hints and sets cookies 77 | * if they are not set then reloads the page if any cookie was set to an 78 | * inaccurate value. 79 | */ 80 | export function ClientHintCheck({ nonce }: { nonce: string }) { 81 | const { revalidate } = useRevalidator() 82 | React.useEffect(() => { 83 | const themeQuery = window.matchMedia('(prefers-color-scheme: dark)') 84 | function handleThemeChange() { 85 | document.cookie = `${clientHints.theme.cookieName}=${ 86 | themeQuery.matches ? 'dark' : 'light' 87 | }` 88 | revalidate() 89 | } 90 | themeQuery.addEventListener('change', handleThemeChange) 91 | return () => { 92 | themeQuery.removeEventListener('change', handleThemeChange) 93 | } 94 | }, [revalidate]) 95 | 96 | return ( 97 |