diff --git a/.dockerignore b/.dockerignore index 538f2652b..936d30a9d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,15 +1,31 @@ -dist -docs +# VCS and editor state +.git +.gitignore +.cursor +.DS_Store + +# Local configuration and runtime state .env* /data + +# Dependency and package-manager caches +node_modules +**/node_modules +.pnpm-store + +# Build, test, and framework output +dist +**/dist .nitro -.cursor .output .vercel .netlify -coverage -.DS_Store .tanstack -node_modules +.turbo +**/.turbo +coverage +reports + +# Generated service-worker assets are rebuilt during the web build. public/sw.js -public/workbox-*.js \ No newline at end of file +public/workbox-*.js diff --git a/.env.example b/.env.example index f27227377..4b16cc8e0 100644 --- a/.env.example +++ b/.env.example @@ -1,18 +1,14 @@ -# --- Server --- -TZ="Etc/UTC" +# --- Application --- +# Port used by the web server in local development and self-hosted containers. +PORT="3000" + +# Public URL where the app is served. Used for auth callbacks, OAuth issuer URLs, +# OpenGraph metadata, and absolute upload URLs. APP_URL="http://localhost:3000" -# Optional, uses APP_URL by default -# PLEASE READ: This should be set to an internal URL (like http://host.docker.internal:3000 or http://{docker_service}:3000) -# to let the browser navigate to a non-public instance of Reactive Resume. -# This is required when the printer service is running inside Docker, and cannot reach the app via the APP URL, -# which is usually when the APP_URL is localhost or a local network IP/hostname. -PRINTER_APP_URL="http://host.docker.internal:3000" - -# --- Printer --- -PRINTER_ENDPOINT="ws://localhost:4000?token=change-me" - # --- Database (PostgreSQL) --- +# PostgreSQL connection URL. In Docker Compose, the hostname is usually `postgres`; +# when running directly on your machine, `localhost` is typical. DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" # --- Authentication --- @@ -20,34 +16,45 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" AUTH_SECRET="change-me-to-a-secure-secret-key-in-production" # Better Auth Dashboard (optional) +# Enables the Better Auth Dashboard plugin when set, you probably don't need this. BETTER_AUTH_API_KEY="" # Social Auth (Google, optional) +# Set both values to enable Google sign-in. GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_SECRET="" # Social Auth (GitHub, optional) +# Set both values to enable GitHub sign-in. GITHUB_CLIENT_ID="" GITHUB_CLIENT_SECRET="" # Social Auth (LinkedIn, optional) +# Set both values to enable LinkedIn sign-in. LINKEDIN_CLIENT_ID="" LINKEDIN_CLIENT_SECRET="" # Custom OAuth Provider (optional) +# Set OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET plus either OAUTH_DISCOVERY_URL or +# the three manual endpoint URLs below. OAUTH_PROVIDER_NAME="" OAUTH_CLIENT_ID="" OAUTH_CLIENT_SECRET="" OAUTH_DISCOVERY_URL="" OAUTH_AUTHORIZATION_URL="" +OAUTH_TOKEN_URL="" +OAUTH_USER_INFO_URL="" + +# Space-separated scopes requested from the custom OAuth provider. +OAUTH_SCOPES="openid profile email" + +# Comma-separated extra hosts/origins allowed for dynamic OAuth client redirect URIs. +# By default, only the APP_URL origin is allowed. OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS="" -# AI provider base URL allowlist (optional, comma-separated hosts/origins) -# Example: api.openai.com,https://gateway.ai.vercel.com -AI_ALLOWED_BASE_URLS="" - # --- Email (optional) --- -# If all keys are disabled, the app logs the email to be sent to the console instead. +# If SMTP_HOST, SMTP_USER, SMTP_PASS, or SMTP_FROM is missing, the app logs the +# email to the console instead. SMTP_HOST="localhost" SMTP_PORT="1025" SMTP_USER="" @@ -68,14 +75,11 @@ S3_BUCKET="reactive-resume" S3_FORCE_PATH_STYLE="true" # --- Feature Flags --- -# This flag bypasses the check that the endpoint to fetch data for the printing of PDFs `getByIdForPrinter`, is only accessible from the server. -# Useful for when you want to debug the /printer/{resumeId} route to quickly take a peek at the page that is sent to the printer. -FLAG_DEBUG_PRINTER="false" - # This flag disables new signups, both on the web app and the server. FLAG_DISABLE_SIGNUPS="false" -# This flag disables email/password login. Disables email verification, forgot password, and reset password flows. Users can still sign up via social auth (Google/GitHub/Custom OAuth), unless FLAG_DISABLE_SIGNUPS is also set to true. +# This flag disables email/password login. Disables email verification, forgot password, and reset password flows. +# Users can still sign up via social auth (Google/GitHub/Custom OAuth), unless FLAG_DISABLE_SIGNUPS is also set to true. FLAG_DISABLE_EMAIL_AUTH="false" # This flag disables the image processing. @@ -84,10 +88,11 @@ FLAG_DISABLE_IMAGE_PROCESSING="false" # --- Others --- # Google Cloud API Key (optional) -# This is not used within Reactive Resume, but in src/scripts/fonts/generate.ts to generate a list of fonts served by Google Fonts. -# Note: Make sure "Google Fonts Developer API" is unrestricted. +# For font-list generation tooling. +# Requires "Google Fonts Developer API" to be enabled. GOOGLE_CLOUD_API_KEY="" -# --- Crowdin --- +# Crowdin (optional) +# For translation tooling. CROWDIN_PROJECT_ID="" -CROWDIN_API_TOKEN="" \ No newline at end of file +CROWDIN_API_TOKEN="" diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index c65231d10..000000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -locales/*.po linguist-generated=true \ No newline at end of file diff --git a/.github/.well-known/funding.json b/.github/.well-known/funding.json index bc2b25393..37b1f500f 100644 --- a/.github/.well-known/funding.json +++ b/.github/.well-known/funding.json @@ -1,68 +1,68 @@ { - "$schema": "https://fundingjson.org/schema/v1.1.0.json", - "version": "v1.1.0", - "entity": { - "type": "individual", - "role": "maintainer", - "name": "Amruth Pillai", - "email": "im.amruth@gmail.com", - "description": "Software Engineer", - "webpageUrl": { - "url": "https://rxresu.me/funding.json" - } - }, - "projects": [ - { - "guid": "reactive-resume", - "name": "Reactive Resume", - "description": "A free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.", - "webpageUrl": { - "url": "https://rxresu.me" - }, - "repositoryUrl": { - "url": "https://github.com/amruthpillai/reactive-resume", - "wellKnown": "https://github.com/amruthpillai/reactive-resume/blob/main/.github/.well-known/funding-manifest-urls" - }, - "licenses": ["spdx:MIT"], - "tags": ["data", "design", "productivity", "resume-builder"] - } - ], - "funding": { - "plans": [ - { - "guid": "sponsor", - "status": "active", - "name": "Sponsor", - "description": "Support the project on a recurring basis by becoming a sponsor.", - "amount": 10, - "currency": "EUR", - "frequency": "monthly", - "channels": ["github", "open-collective"] - }, - { - "guid": "donation", - "status": "active", - "name": "Donation", - "description": "Show your support for the project by making a one-time donation.", - "amount": 0, - "currency": "EUR", - "frequency": "one-time", - "channels": ["github", "open-collective"] - } - ], - "channels": [ - { - "guid": "github", - "type": "payment-provider", - "description": "GitHub Sponsors", - "address": "https://github.com/sponsors/AmruthPillai" - }, - { - "guid": "open-collective", - "type": "payment-provider", - "description": "Open Collective", - "address": "https://opencollective.com/reactive-resume" - } - ] - } + "$schema": "https://fundingjson.org/schema/v1.1.0.json", + "version": "v1.1.0", + "entity": { + "type": "individual", + "role": "maintainer", + "name": "Amruth Pillai", + "email": "hello@amruthpillai.com", + "description": "Software Engineer", + "webpageUrl": { + "url": "https://rxresu.me/funding.json" + } + }, + "projects": [ + { + "guid": "reactive-resume", + "name": "Reactive Resume", + "description": "A free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.", + "webpageUrl": { + "url": "https://rxresu.me" + }, + "repositoryUrl": { + "url": "https://github.com/amruthpillai/reactive-resume", + "wellKnown": "https://github.com/amruthpillai/reactive-resume/blob/main/.github/.well-known/funding-manifest-urls" + }, + "licenses": ["spdx:MIT"], + "tags": ["data", "design", "productivity", "resume-builder"] + } + ], + "funding": { + "plans": [ + { + "guid": "sponsor", + "status": "active", + "name": "Sponsor", + "description": "Support the project on a recurring basis by becoming a sponsor.", + "amount": 10, + "currency": "EUR", + "frequency": "monthly", + "channels": ["github", "open-collective"] + }, + { + "guid": "donation", + "status": "active", + "name": "Donation", + "description": "Show your support for the project by making a one-time donation.", + "amount": 0, + "currency": "EUR", + "frequency": "one-time", + "channels": ["github", "open-collective"] + } + ], + "channels": [ + { + "guid": "github", + "type": "payment-provider", + "description": "GitHub Sponsors", + "address": "https://github.com/sponsors/AmruthPillai" + }, + { + "guid": "open-collective", + "type": "payment-provider", + "description": "Open Collective", + "address": "https://opencollective.com/reactive-resume" + } + ] + } } diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 437e06a9f..5a491c6ad 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -19,20 +19,26 @@ jobs: - name: Checkout Repository uses: actions/checkout@v6 - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Install pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node + uses: actions/setup-node@v6 with: - node-version: "24" - cache: true + node-version: "lts/*" + cache: "pnpm" - name: Install Dependencies - run: vp install --frozen-lockfile + run: pnpm install --frozen-lockfile - - name: Run Tests - run: vp test + - name: Remove Unused Dependencies + run: pnpm knip --fix - - name: Run Lint and Format - run: vp check --fix + - name: Lint and Format + run: pnpm check + + - name: Extract Translations + run: pnpm lingui:extract - name: Autofix uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 diff --git a/.gitignore b/.gitignore index 9427847a8..a2fb422e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,51 @@ -dist -.env* -/data -.nitro -.agents +# Dependencies +node_modules +.pnpm-store + +# Build Outputs .output .vercel -.cursor -.claude -TODO.md -coverage -.netlify -.DS_Store -.tanstack -node_modules +.wrangler + +# Environment Variables +.env* !.env.example -public/sw.js -public/sw.js.map -scripts/**/*.json -public/workbox-*.js -public/workbox-*.js.map \ No newline at end of file + +# IDEs and Editors +*~ +*.swp +*.swo +.DS_Store +.idea +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# Log Files +logs +*.log* + +# Turborepo +.turbo + +# TanStack Start +.tanstack + +# Testing +coverage +reports + +# Cache +tmp +temp +.cache + +# AI +.codex +.claude +.cursor + +# Data +apps/web/data diff --git a/.ncurc.cjs b/.ncurc.cjs index 2998a31a4..80a12c400 100644 --- a/.ncurc.cjs +++ b/.ncurc.cjs @@ -1,17 +1,15 @@ // @ts-check -const nextPackages = ["@monaco-editor/react"]; - const betaPackages = ["drizzle-orm", "drizzle-kit", "drizzle-zod"]; /** @type {import('npm-check-updates').RunOptions} */ module.exports = { - upgrade: true, - install: "always", - packageManager: "pnpm", - target: (packageName) => { - if (nextPackages.includes(packageName)) return "@next"; - if (betaPackages.includes(packageName)) return "@beta"; - return "latest"; - }, + upgrade: true, + workspaces: true, + install: "always", + packageManager: "pnpm", + target: (packageName) => { + if (betaPackages.includes(packageName)) return "@beta"; + return "latest"; + }, }; diff --git a/.vite-hooks/_/commit-msg b/.vite-hooks/_/commit-msg new file mode 100755 index 000000000..6f7c3d996 --- /dev/null +++ b/.vite-hooks/_/commit-msg @@ -0,0 +1,71 @@ +#!/bin/sh + +if [ "$LEFTHOOK_VERBOSE" = "1" -o "$LEFTHOOK_VERBOSE" = "true" ]; then + set -x +fi + +if [ "$LEFTHOOK" = "0" ]; then + exit 0 +fi + +call_lefthook() +{ + if test -n "$LEFTHOOK_BIN" + then + "$LEFTHOOK_BIN" "$@" + elif lefthook -h >/dev/null 2>&1 + then + lefthook "$@" + elif /Users/amruth/Projects/reactive-resume/node_modules/.pnpm/lefthook-darwin-arm64@2.1.6/node_modules/lefthook-darwin-arm64/bin/lefthook -h >/dev/null 2>&1 + then + /Users/amruth/Projects/reactive-resume/node_modules/.pnpm/lefthook-darwin-arm64@2.1.6/node_modules/lefthook-darwin-arm64/bin/lefthook "$@" + else + dir="$(git rev-parse --show-toplevel)" + osArch=$(uname | tr '[:upper:]' '[:lower:]') + cpuArch=$(uname -m | sed 's/aarch64/arm64/;s/x86_64/x64/') + if test -f "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook" + then + "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook" "$@" + elif test -f "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook" + then + "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook" "$@" + elif test -f "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook" + then + "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook" "$@" + elif test -f "$dir/node_modules/lefthook/bin/index.js" + then + "$dir/node_modules/lefthook/bin/index.js" "$@" + elif go tool lefthook -h >/dev/null 2>&1 + then + go tool lefthook "$@" + elif bundle exec lefthook -h >/dev/null 2>&1 + then + bundle exec lefthook "$@" + elif yarn lefthook -h >/dev/null 2>&1 + then + yarn lefthook "$@" + elif pnpm lefthook -h >/dev/null 2>&1 + then + pnpm lefthook "$@" + elif swift package lefthook >/dev/null 2>&1 + then + swift package --build-path .build/lefthook --disable-sandbox lefthook "$@" + elif command -v mint >/dev/null 2>&1 + then + mint run csjones/lefthook-plugin "$@" + elif uv run lefthook -h >/dev/null 2>&1 + then + uv run lefthook "$@" + elif mise exec -- lefthook -h >/dev/null 2>&1 + then + mise exec -- lefthook "$@" + elif devbox run lefthook -h >/dev/null 2>&1 + then + devbox run lefthook "$@" + else + echo "Can't find lefthook in PATH" + fi + fi +} + +call_lefthook run "commit-msg" "$@" diff --git a/.vite-hooks/_/pre-commit b/.vite-hooks/_/pre-commit new file mode 100755 index 000000000..38d25d166 --- /dev/null +++ b/.vite-hooks/_/pre-commit @@ -0,0 +1,71 @@ +#!/bin/sh + +if [ "$LEFTHOOK_VERBOSE" = "1" -o "$LEFTHOOK_VERBOSE" = "true" ]; then + set -x +fi + +if [ "$LEFTHOOK" = "0" ]; then + exit 0 +fi + +call_lefthook() +{ + if test -n "$LEFTHOOK_BIN" + then + "$LEFTHOOK_BIN" "$@" + elif lefthook -h >/dev/null 2>&1 + then + lefthook "$@" + elif /Users/amruth/Projects/reactive-resume/node_modules/.pnpm/lefthook-darwin-arm64@2.1.6/node_modules/lefthook-darwin-arm64/bin/lefthook -h >/dev/null 2>&1 + then + /Users/amruth/Projects/reactive-resume/node_modules/.pnpm/lefthook-darwin-arm64@2.1.6/node_modules/lefthook-darwin-arm64/bin/lefthook "$@" + else + dir="$(git rev-parse --show-toplevel)" + osArch=$(uname | tr '[:upper:]' '[:lower:]') + cpuArch=$(uname -m | sed 's/aarch64/arm64/;s/x86_64/x64/') + if test -f "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook" + then + "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook" "$@" + elif test -f "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook" + then + "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook" "$@" + elif test -f "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook" + then + "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook" "$@" + elif test -f "$dir/node_modules/lefthook/bin/index.js" + then + "$dir/node_modules/lefthook/bin/index.js" "$@" + elif go tool lefthook -h >/dev/null 2>&1 + then + go tool lefthook "$@" + elif bundle exec lefthook -h >/dev/null 2>&1 + then + bundle exec lefthook "$@" + elif yarn lefthook -h >/dev/null 2>&1 + then + yarn lefthook "$@" + elif pnpm lefthook -h >/dev/null 2>&1 + then + pnpm lefthook "$@" + elif swift package lefthook >/dev/null 2>&1 + then + swift package --build-path .build/lefthook --disable-sandbox lefthook "$@" + elif command -v mint >/dev/null 2>&1 + then + mint run csjones/lefthook-plugin "$@" + elif uv run lefthook -h >/dev/null 2>&1 + then + uv run lefthook "$@" + elif mise exec -- lefthook -h >/dev/null 2>&1 + then + mise exec -- lefthook "$@" + elif devbox run lefthook -h >/dev/null 2>&1 + then + devbox run lefthook "$@" + else + echo "Can't find lefthook in PATH" + fi + fi +} + +call_lefthook run "pre-commit" "$@" diff --git a/.vite-hooks/pre-commit b/.vite-hooks/pre-commit deleted file mode 100755 index 85fb65b4f..000000000 --- a/.vite-hooks/pre-commit +++ /dev/null @@ -1 +0,0 @@ -vp staged diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 74ed08e01..0e6f72ac0 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,9 +1,3 @@ { - "recommendations": [ - "oxc.oxc-vscode", - "bradlc.vscode-tailwindcss", - "lokalise.i18n-ally", - "typescriptteam.native-preview", - "VoidZero.vite-plus-extension-pack" - ] + "recommendations": ["biomejs.biome", "bradlc.vscode-tailwindcss", "typescriptteam.native-preview"] } diff --git a/.vscode/settings.json b/.vscode/settings.json index f7cad65e1..a028ad7be 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,42 +1,31 @@ { - "biome.enabled": false, - "editor.defaultFormatter": "oxc.oxc-vscode", - "editor.codeActionsOnSave": { - "source.fixAll.oxc": "explicit" - }, - "editor.formatOnSave": true, - "editor.formatOnSaveMode": "file", - "files.associations": { - "*.css": "tailwindcss" - }, - "files.readonlyInclude": { - "**/routeTree.gen.ts": true, - "pnpm-lock.yaml": true - }, - "files.watcherExclude": { - "**/routeTree.gen.ts": true, - "locales/**.po": true, - "pnpm-lock.yaml": true - }, - "i18n-ally.enabledParsers": ["po"], - "i18n-ally.localesPaths": ["locales"], - "i18n-ally.sourceLanguage": "en-US", - "oxc.enable.oxfmt": true, - "oxc.enable.oxlint": true, - "oxc.configPath": "./vite.config.ts", - "oxc.fmt.configPath": "./vite.config.ts", - "search.exclude": { - "**/routeTree.gen.ts": true, - "locales/**.po": true, - "pnpm-lock.yaml": true - }, - "tailwindCSS.classFunctions": ["cn", "cva"], - "tailwindCSS.experimental.classRegex": [ - ["class:\\s*?[\"'`]([^\"'`]*).*?,"], - ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], - ["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] - ], - "tailwindCSS.experimental.configFile": "src/styles/globals.css", - "typescript.experimental.useTsgo": true, - "i18n-ally.keystyle": "nested" + "biome.enabled": true, + "editor.codeActionsOnSave": { + "source.fixAll.biome": "explicit", + "source.organizeImports.biome": "explicit" + }, + "editor.defaultFormatter": "biomejs.biome", + "files.readonlyInclude": { + "**/locales/**.po": true, + "**/routeTree.gen.ts": true, + "pnpm-lock.yaml": true + }, + "files.watcherExclude": { + "**/locales/**.po": true, + "**/routeTree.gen.ts": true, + "pnpm-lock.yaml": true + }, + "search.exclude": { + "**/locales/**.po": true, + "**/routeTree.gen.ts": true, + "pnpm-lock.yaml": true + }, + "tailwindCSS.classFunctions": ["cn", "cva"], + "tailwindCSS.experimental.classRegex": [ + ["class:\\s*?[\"'`]([^\"'`]*).*?,"], + ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], + ["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] + ], + "tailwindCSS.experimental.configFile": "src/styles/globals.css", + "typescript.experimental.useTsgo": true } diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 02de9804e..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,131 +0,0 @@ -## Overview - -Reactive Resume is a single-package full-stack TypeScript app (not a monorepo) built with [TanStack Start](https://tanstack.com/start/latest/docs/framework/react/overview) (React, Vite, Nitro). It serves both frontend and API on port 3000. - -This project uses [Vite+](https://vite.dev/blog/announcing-viteplus), a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called `vp`. All modules should be imported from the `vite-plus` dependency (e.g., `import { defineConfig } from 'vite-plus'` or `import { expect, test, vi } from 'vite-plus/test'`). - -## Key Libraries - -| Area | Library | Docs | -| -------------------- | ------------------------------------------------------------------------ | ---------------------------------- | -| Frontend framework | React | https://react.dev | -| Full-stack framework | TanStack Start | https://tanstack.com/start/latest | -| Router | TanStack React Router | https://tanstack.com/router/latest | -| Server state | TanStack React Query | https://tanstack.com/query/latest | -| Client state | Zustand (+ Zundo for undo/redo, Immer for immutable updates) | https://zustand.docs.pmnd.rs | -| Type-safe API | oRPC | https://orpc.unnoq.com | -| Database ORM | Drizzle ORM (PostgreSQL) | https://orm.drizzle.team | -| Authentication | Better Auth (+ Drizzle adapter, OAuth provider, API keys, 2FA, Passkeys) | https://www.better-auth.com | -| Styling | Tailwind CSS | https://tailwindcss.com | -| UI Components | shadcn/ui (built on Base UI) | https://ui.shadcn.com | -| Icons | Phosphor Icons | https://phosphoricons.com | -| Forms | React Hook Form (+ Zod resolvers) | https://react-hook-form.com | -| Rich text editor | Tiptap | https://tiptap.dev | -| Validation | Zod | https://zod.dev | -| AI | Vercel AI SDK (OpenAI, Anthropic, Google, Ollama providers) | https://ai-sdk.dev | -| MCP | Model Context Protocol SDK | https://modelcontextprotocol.io | -| i18n | Lingui | https://lingui.dev | -| Animations | Motion (Framer Motion) | https://motion.dev | -| PDF export | Puppeteer Core (via Browserless) | https://pptr.dev | -| Drag and drop | dnd-kit | https://dndkit.com | -| Server engine | Nitro | https://nitro.build | -| PWA | Vite PWA Plugin | https://vite-pwa-org.netlify.app | -| Unused deps | Knip | https://knip.dev | - -## Project Structure - -``` -src/ - components/ UI, resume, layout, animation, theme, locale components - routes/ File-based routing (TanStack React Router) - integrations/ Feature modules (auth, drizzle, orpc, ai, email, jobs, mcp, storage) - schema/ Zod schemas for resume data validation - utils/ Utility functions (locale, theme, env, resume processing) - dialogs/ Modal/dialog components - hooks/ Custom React hooks - styles/ CSS and Tailwind configuration - stores/ Zustand stores (resume, AI, dialog, command palette) -migrations/ Drizzle database migrations -locales/ Lingui i18n message catalogs (47+ locales) -``` - -### Key Config Files - -- `vite.config.ts` — Vite + Nitro + TanStack Start + PWA + Tailwind + Lingui -- `drizzle.config.ts` — PostgreSQL dialect, schema at `./src/integrations/drizzle/schema.ts` -- `tsconfig.json` — ES2022, strict mode, path alias `@/*` → `./src/*` -- `lingui.config.ts` — i18n extraction and locale configuration -- `components.json` — shadcn CLI configuration - -### API Architecture - -- **oRPC API** (`/api/rpc/*`) — Type-safe RPC with routers for: `ai`, `auth`, `resume`, `storage`, `printer`, `jobs`, `statistics`, `flags`. Three procedure types: `publicProcedure`, `protectedProcedure`, `serverOnlyProcedure`. -- **Better Auth API** (`/api/auth/*`) — OAuth, session management, social provider callbacks. -- **MCP Server** (`/mcp/`) — Model Context Protocol with OAuth Bearer tokens and API key auth. Exposes resumes as resources and tools for resume CRUD. - -## Infrastructure Services - -Before running the dev server, Docker must be running with at least PostgreSQL. Start services via `compose.dev.yml`: - -```bash -sudo dockerd &>/var/log/dockerd.log & -sudo docker compose -f compose.dev.yml up -d postgres browserless -``` - -- **PostgreSQL** (port 5432) — required. The app auto-runs Drizzle migrations on startup via a Nitro plugin. -- **Browserless** (port 4000) — required for PDF export. Maps container port 3000 to host port 4000. - -## Environment Variables - -Copy `.env.example` to `.env` if not present. Key notes for local dev: - -- `APP_URL` — local dev server origin on port 3000. -- `PRINTER_APP_URL` — must use the Docker bridge gateway IP (not localhost) so the Browserless container can reach the app on the host. Get the IP with: `sudo docker network inspect reactive_resume_default --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}'` -- `PRINTER_ENDPOINT` — websocket URL to Browserless on host port 4000 with token `1234567890`. -- `DATABASE_URL` — PostgreSQL connection using `postgres:postgres` credentials on localhost:5432. -- S3/Storage and SMTP vars can be left empty — the app falls back to local filesystem and console-logged emails. - -## Common Commands - -`vp` is the global CLI for Vite+. Do not use pnpm/npm/yarn directly — Vite+ wraps the underlying package manager. - -| Task | Command | -| ----------------------------- | --------------------------------------------------------------- | -| Install dependencies | `vp install` | -| Dev server (port 3000) | `vp dev` | -| Lint (Oxlint, type-aware) | `vp lint --type-aware` | -| Format (Oxfmt) | `vp fmt` | -| Check (lint + format + types) | `vp check` | -| Typecheck | `pnpm typecheck` (uses tsgo) | -| Run tests | `vp test` | -| DB migrations | `pnpm db:generate` / `pnpm db:migrate` (auto-runs on dev start) | -| DB studio | `pnpm db:studio` | -| i18n extraction | `pnpm lingui:extract` | -| Add a dependency | `vp add ` | -| Remove a dependency | `vp remove ` | -| One-off binary | `vp dlx ` | -| Build for production | `vp build` | -| Preview production build | `vp preview` | -| Start production server | `pnpm start` | - -## Vite+ Pitfalls - -- **Do not use pnpm/npm/yarn directly** for package operations — use `vp add`, `vp remove`, `vp install`, etc. -- **Do not run `vp vitest` or `vp oxlint`** — they don't exist. Use `vp test` and `vp lint`. -- **Do not install Vitest, Oxlint, Oxfmt, or tsdown directly** — Vite+ bundles them. -- **Import from `vite-plus`**, not from `vite` or `vitest` directly (e.g., `import { defineConfig } from 'vite-plus'`). -- **Vite+ commands take precedence** over `package.json` scripts. If there's a naming conflict, use `vp run ')).toBe(""); - }); - - it("should strip event handlers", () => { - expect(sanitizeHtml('')).toBe(""); - }); - - it("should allow links with href", () => { - const html = 'link'; - expect(sanitizeHtml(html)).toContain('href="https://example.com"'); - }); - - it("should strip javascript: hrefs", () => { - const result = sanitizeHtml('click'); - expect(result).not.toContain("javascript:"); - }); - - it("should allow list elements", () => { - const html = "
  • one
  • two
"; - expect(sanitizeHtml(html)).toBe(html); - }); - - it("should allow table elements", () => { - const html = "
cell
"; - expect(sanitizeHtml(html)).toContain(""); - }); - - it("should preserve safe inline text colors", () => { - const html = '

colored

'; - const result = sanitizeHtml(html); - - expect(result).toContain(" { - it("should return empty string for empty input", () => { - expect(sanitizeCss("")).toBe(""); - }); - - it("should pass through normal CSS", () => { - expect(sanitizeCss("color: red;")).toBe("color: red;"); - }); - - it("should strip javascript: expressions", () => { - expect(sanitizeCss("background: javascript:alert(1)")).not.toContain("javascript:"); - }); - - it("should strip expression() calls", () => { - expect(sanitizeCss("width: expression(alert(1))")).not.toContain("expression("); - }); - - it("should strip behavior: property", () => { - expect(sanitizeCss("behavior: url(evil.htc)")).not.toContain("behavior:"); - }); - - it("should strip -moz-binding", () => { - expect(sanitizeCss("-moz-binding: url(evil.xml)")).not.toContain("-moz-binding:"); - }); - - it("should strip @import rules", () => { - const css = "@import url('https://evil.example/css'); .safe { color: red; }"; - const result = sanitizeCss(css); - expect(result).not.toContain("@import"); - expect(result).toContain(".safe"); - }); - - it("should strip network url() calls and keep regular declarations", () => { - const css = ".card { background: url('https://evil.example/bg.png'); color: #111; }"; - const result = sanitizeCss(css); - expect(result).not.toContain("url("); - expect(result).toMatch(/color\s*:\s*#111/); - }); - - it("should strip @font-face blocks", () => { - const css = - "@font-face { font-family: test; src: url('https://evil.example/font.woff2'); } .x { font-family: test; }"; - const result = sanitizeCss(css); - expect(result).not.toContain("@font-face"); - expect(result).toContain(".x"); - }); - - it("should strip css comments before scanning dangerous values", () => { - const css = ".x { background: java/**/script:alert(1); color: blue; }"; - const result = sanitizeCss(css); - expect(result).not.toContain("javascript:"); - expect(result).toContain("color"); - }); - - it("should decode escaped javascript protocol and strip it", () => { - const css = ".x { background: \\6a\\61\\76\\61\\73\\63\\72\\69\\70\\74:alert(1); color: green; }"; - const result = sanitizeCss(css); - expect(result).not.toContain("javascript:"); - expect(result).toContain("color"); - }); - - it("should decode escaped expression() and strip it", () => { - const css = ".x { width: \\65\\78\\70\\72\\65\\73\\73\\69\\6f\\6e(alert(1)); height: 10px; }"; - const result = sanitizeCss(css); - expect(result).not.toContain("expression("); - expect(result).toContain("height"); - }); - - it("should decode non-hex escapes without dropping characters", () => { - const css = ".x { content: \\(ok\\); color: teal; }"; - const result = sanitizeCss(css); - expect(result).toContain("content: (ok)"); - expect(result).toContain("color: teal"); - }); - - it("should strip data url payloads in url() functions", () => { - const css = ".x { background-image: url(data:text/html;base64,PHNjcmlwdD4=); color: black; }"; - const result = sanitizeCss(css); - expect(result).not.toContain("url("); - expect(result).toContain("color"); - }); - - it("should strip image-set and cross-fade function network payloads", () => { - const css = - ".x { background-image: image-set(url('https://evil.example/a.png') 1x); mask-image: cross-fade(url('https://evil.example/a.png'), url('https://evil.example/b.png')); color: purple; }"; - const result = sanitizeCss(css); - expect(result).not.toContain("image-set("); - expect(result).not.toContain("cross-fade("); - expect(result).toContain("color"); - }); -}); - -describe("isObject", () => { - it("should return true for plain objects", () => { - expect(isObject({})).toBe(true); - expect(isObject({ a: 1 })).toBe(true); - }); - - it("should return false for arrays", () => { - expect(isObject([])).toBe(false); - }); - - it("should return false for null", () => { - expect(isObject(null)).toBe(false); - }); - - it("should return false for primitives", () => { - expect(isObject("string")).toBe(false); - expect(isObject(42)).toBe(false); - expect(isObject(undefined)).toBe(false); - }); -}); diff --git a/src/utils/sanitize.ts b/src/utils/sanitize.ts deleted file mode 100644 index 320de0cda..000000000 --- a/src/utils/sanitize.ts +++ /dev/null @@ -1,99 +0,0 @@ -import DOMPurify, { type Config } from "dompurify"; - -const RICH_TEXT_CONFIG: Config = { - ALLOWED_TAGS: [ - "p", - "br", - "hr", - "span", - "div", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "strong", - "b", - "em", - "i", - "u", - "s", - "strike", - "mark", - "code", - "pre", - "ul", - "ol", - "li", - "table", - "thead", - "tbody", - "tfoot", - "tr", - "th", - "td", - "colgroup", - "col", - "a", - "blockquote", - ], - ALLOWED_ATTR: ["class", "style", "href", "target", "rel", "colspan", "rowspan", "data-type", "data-label"], - ALLOWED_URI_REGEXP: /^(?:(?:https?):\/\/|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i, - ADD_ATTR: ["target", "rel"], - ALLOW_DATA_ATTR: false, -}; - -const PLAIN_TEXT_SANITIZE_CONFIG: Config = { - ALLOWED_TAGS: [], - ALLOWED_ATTR: [], - KEEP_CONTENT: true, - RETURN_TRUSTED_TYPE: false, -}; - -function sanitizePlainTextContent(value: string): string { - return DOMPurify.sanitize(value, PLAIN_TEXT_SANITIZE_CONFIG) as string; -} - -function stripCssComments(value: string): string { - if (!value) return ""; - return value.replace(/\/\*[\s\S]*?\*\//g, ""); -} - -function decodeCssEscapes(value: string): string { - if (!value) return ""; - - return value.replace(/\\([0-9a-fA-F]{1,6})(?:\r\n|[ \t\r\n\f])?|\\(.)/g, (_match, hex, escapedChar) => { - if (hex) return String.fromCodePoint(parseInt(hex, 16)); - return escapedChar ?? ""; - }); -} - -export function sanitizeHtml(html: string): string { - if (!html) return ""; - return DOMPurify.sanitize(html, { ...RICH_TEXT_CONFIG, RETURN_TRUSTED_TYPE: false }) as string; -} - -export function sanitizeCss(css: string): string { - if (!css) return ""; - - const normalized = decodeCssEscapes(stripCssComments(css)); - - const preSanitized = normalized - .replace(/javascript\s*:/gi, "") - .replace(/expression\s*\(/gi, "") - .replace(/behavior\s*:[^;}]*/gi, "") - .replace(/-moz-binding\s*:[^;}]*/gi, ""); - - const sanitized = preSanitized - .replace(/@import[^;]*;/gi, "") - .replace(/@font-face\s*\{[^}]*\}/gi, "") - .replace(/\b(?:url|image-set|cross-fade)\s*\([^)]*\)/gi, "") - .replace(/^\s*src\s*:[^;]*;?/gim, ""); - - return sanitizePlainTextContent(sanitized); -} - -export function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/src/utils/string.test.ts b/src/utils/string.test.ts deleted file mode 100644 index 9e67a6d81..000000000 --- a/src/utils/string.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { generateRandomName, getInitials, slugify, stripHtml, toUsername } from "./string"; - -describe("slugify", () => { - it("should lowercase and hyphenate spaces", () => { - expect(slugify("Hello World")).toBe("hello-world"); - }); - - it("should strip special characters", () => { - expect(slugify("Résumé & CV!")).toBe("resume-and-cv"); - }); - - it("should not decamelize camelCase strings", () => { - expect(slugify("camelCase")).toBe("camelcase"); - }); - - it("should return empty string for empty input", () => { - expect(slugify("")).toBe(""); - }); -}); - -describe("getInitials", () => { - it("should return up to two uppercase initials", () => { - expect(getInitials("John Doe")).toBe("JD"); - }); - - it("should return one initial for a single name", () => { - expect(getInitials("Alice")).toBe("A"); - }); - - it("should take only the first two words", () => { - expect(getInitials("John Michael Doe")).toBe("JM"); - }); -}); - -describe("toUsername", () => { - it("should lowercase and strip disallowed characters", () => { - expect(toUsername("John Doe!")).toBe("johndoe"); - }); - - it("should keep dots, hyphens, and underscores", () => { - expect(toUsername("john.doe-name_ok")).toBe("john.doe-name_ok"); - }); - - it("should trim whitespace", () => { - expect(toUsername(" alice ")).toBe("alice"); - }); - - it("should truncate to 64 characters", () => { - const long = "a".repeat(100); - expect(toUsername(long)).toHaveLength(64); - }); -}); - -describe("stripHtml", () => { - it("should remove HTML tags and trim", () => { - expect(stripHtml("

Hello World

")).toBe("Hello World"); - }); - - it("should return empty string for undefined", () => { - expect(stripHtml(undefined)).toBe(""); - }); - - it("should return empty string for empty string", () => { - expect(stripHtml("")).toBe(""); - }); - - it("should handle nested tags", () => { - expect(stripHtml("
  • item
")).toBe("item"); - }); -}); - -describe("generateRandomName", () => { - it("should generate a 3-word capitalized name", () => { - const result = generateRandomName(); - const parts = result.split(" "); - - expect(parts).toHaveLength(3); - for (const part of parts) { - expect(part.length).toBeGreaterThan(0); - expect(part[0]).toBe(part[0].toUpperCase()); - } - }); -}); diff --git a/src/utils/style.test.ts b/src/utils/style.test.ts deleted file mode 100644 index 53fe467e9..000000000 --- a/src/utils/style.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { cn } from "./style"; - -describe("cn", () => { - it("merges simple class names", () => { - expect(cn("foo", "bar")).toBe("foo bar"); - }); - - it("handles conditional classes (falsy values ignored)", () => { - const falsyValue = false; - expect(cn("base", falsyValue && "hidden", "active")).toBe("base active"); - }); - - it("handles undefined and null", () => { - expect(cn("base", undefined, null, "end")).toBe("base end"); - }); - - it("merges Tailwind classes (last wins)", () => { - expect(cn("p-4", "p-8")).toBe("p-8"); - }); - - it("merges conflicting Tailwind utilities", () => { - expect(cn("text-red-500", "text-blue-500")).toBe("text-blue-500"); - }); - - it("keeps non-conflicting Tailwind classes", () => { - const result = cn("p-4", "m-2", "text-red-500"); - expect(result).toContain("p-4"); - expect(result).toContain("m-2"); - expect(result).toContain("text-red-500"); - }); - - it("handles empty input", () => { - expect(cn()).toBe(""); - }); - - it("handles array input", () => { - expect(cn(["foo", "bar"])).toBe("foo bar"); - }); - - it("handles object input", () => { - expect(cn({ foo: true, bar: false, baz: true })).toBe("foo baz"); - }); -}); diff --git a/src/utils/style.ts b/src/utils/style.ts deleted file mode 100644 index 365058ceb..000000000 --- a/src/utils/style.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} diff --git a/src/utils/theme.test.ts b/src/utils/theme.test.ts deleted file mode 100644 index 5da36441c..000000000 --- a/src/utils/theme.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; - -vi.mock("@lingui/core/macro", () => ({ - msg: (strings: TemplateStringsArray) => ({ id: strings[0] }), -})); - -vi.mock("@tanstack/react-start", () => { - const chainable = () => new Proxy({}, { get: () => chainable }); - return { - createIsomorphicFn: chainable, - createServerFn: chainable, - }; -}); - -vi.mock("@tanstack/react-start/server", () => ({ - getCookie: () => undefined, - setCookie: () => {}, -})); - -vi.mock("js-cookie", () => ({ - default: { get: () => undefined }, -})); - -import { isTheme } from "./theme"; - -describe("isTheme", () => { - it("returns true for 'light'", () => { - expect(isTheme("light")).toBe(true); - }); - - it("returns true for 'dark'", () => { - expect(isTheme("dark")).toBe(true); - }); - - it("returns false for invalid theme strings", () => { - expect(isTheme("auto")).toBe(false); - expect(isTheme("system")).toBe(false); - expect(isTheme("")).toBe(false); - expect(isTheme("DARK")).toBe(false); - expect(isTheme("Light")).toBe(false); - }); -}); diff --git a/src/utils/url-security.test.ts b/src/utils/url-security.test.ts deleted file mode 100644 index 4afc7d51e..000000000 --- a/src/utils/url-security.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - isAllowedOAuthRedirectUri, - isAllowedExternalUrl, - isPrivateOrLoopbackHost, - parseAllowedHostList, - parseUrl, - sanitizeResumePictureUrl, -} from "./url-security"; - -describe("isPrivateOrLoopbackHost", () => { - it("returns true for localhost and private hosts", () => { - expect(isPrivateOrLoopbackHost("localhost")).toBe(true); - expect(isPrivateOrLoopbackHost("127.0.0.1")).toBe(true); - expect(isPrivateOrLoopbackHost("10.0.0.7")).toBe(true); - expect(isPrivateOrLoopbackHost("192.168.0.2")).toBe(true); - }); - - it("returns false for public hosts", () => { - expect(isPrivateOrLoopbackHost("example.com")).toBe(false); - expect(isPrivateOrLoopbackHost("8.8.8.8")).toBe(false); - }); - - it("returns true for private and link-local ipv6 addresses", () => { - expect(isPrivateOrLoopbackHost("::1")).toBe(true); - expect(isPrivateOrLoopbackHost("[::1]")).toBe(true); - expect(isPrivateOrLoopbackHost("fd00::1234")).toBe(true); - expect(isPrivateOrLoopbackHost("[fe80::1]")).toBe(true); - }); -}); - -describe("isAllowedExternalUrl", () => { - const allowedHosts = new Set(["api.openai.com", "https://gateway.ai.vercel.com"]); - - it("allows https URLs on allowed hosts", () => { - expect(isAllowedExternalUrl("https://api.openai.com/v1", allowedHosts)).toBe(true); - expect(isAllowedExternalUrl("https://gateway.ai.vercel.com/v1", allowedHosts)).toBe(true); - }); - - it("blocks local and non-https URLs", () => { - expect(isAllowedExternalUrl("http://api.openai.com/v1", allowedHosts)).toBe(false); - expect(isAllowedExternalUrl("https://localhost:11434/v1", allowedHosts)).toBe(false); - expect(isAllowedExternalUrl("https://10.0.0.1/v1", allowedHosts)).toBe(false); - }); - - it("blocks malformed URLs and credentialed URLs", () => { - expect(isAllowedExternalUrl("not-a-url", allowedHosts)).toBe(false); - expect(isAllowedExternalUrl("https://user:pass@api.openai.com/v1", allowedHosts)).toBe(false); - }); - - it("supports origin-only entries in the allow list", () => { - const originOnlyAllowedHosts = new Set(["https://example.org"]); - expect(isAllowedExternalUrl("https://example.org/v1/hello", originOnlyAllowedHosts)).toBe(true); - expect(isAllowedExternalUrl("https://sub.example.org/v1/hello", originOnlyAllowedHosts)).toBe(false); - }); -}); - -describe("isAllowedOAuthRedirectUri", () => { - const trustedOrigins = ["https://rxresu.me"]; - const allowedHosts = new Set(["https://client.example.com", "trusted.example.com"]); - - it("allows local loopback HTTP callbacks for native OAuth clients", () => { - expect(isAllowedOAuthRedirectUri("http://localhost:6188/callback", trustedOrigins, allowedHosts)).toBe(true); - expect(isAllowedOAuthRedirectUri("http://127.0.0.1:6188/callback", trustedOrigins, allowedHosts)).toBe(true); - expect(isAllowedOAuthRedirectUri("http://[::1]:6188/callback", trustedOrigins, allowedHosts)).toBe(true); - }); - - it("keeps rejecting unsafe HTTP and malformed redirects", () => { - expect(isAllowedOAuthRedirectUri("http://192.168.1.20:6188/callback", trustedOrigins, allowedHosts)).toBe(false); - expect(isAllowedOAuthRedirectUri("http://example.com/callback", trustedOrigins, allowedHosts)).toBe(false); - expect(isAllowedOAuthRedirectUri("not-a-url", trustedOrigins, allowedHosts)).toBe(false); - }); - - it("allows only trusted HTTPS redirects without credentials or fragments", () => { - expect(isAllowedOAuthRedirectUri("https://rxresu.me/callback", trustedOrigins, allowedHosts)).toBe(true); - expect(isAllowedOAuthRedirectUri("https://client.example.com/callback", trustedOrigins, allowedHosts)).toBe(true); - expect(isAllowedOAuthRedirectUri("https://trusted.example.com/callback", trustedOrigins, allowedHosts)).toBe(true); - expect(isAllowedOAuthRedirectUri("https://evil.example.com/callback", trustedOrigins, allowedHosts)).toBe(false); - expect(isAllowedOAuthRedirectUri("https://user:pass@rxresu.me/callback", trustedOrigins, allowedHosts)).toBe(false); - expect(isAllowedOAuthRedirectUri("https://rxresu.me/callback#token", trustedOrigins, allowedHosts)).toBe(false); - }); -}); - -describe("sanitizeResumePictureUrl", () => { - const appUrl = "https://rxresu.me"; - - it("keeps local uploads paths", () => { - expect(sanitizeResumePictureUrl("/uploads/user/pictures/a.webp", appUrl)).toBe("/uploads/user/pictures/a.webp"); - }); - - it("converts same-origin upload URLs to path-only values", () => { - expect(sanitizeResumePictureUrl("https://rxresu.me/uploads/u/p.jpg", appUrl)).toBe("/uploads/u/p.jpg"); - }); - - it("keeps query and hash for same-origin upload URLs", () => { - expect(sanitizeResumePictureUrl("https://rxresu.me/uploads/u/p.jpg?size=2#thumb", appUrl)).toBe( - "/uploads/u/p.jpg?size=2#thumb", - ); - }); - - it("rejects non-upload and cross-origin URLs", () => { - expect(sanitizeResumePictureUrl("https://example.com/pic.jpg", appUrl)).toBe(""); - expect(sanitizeResumePictureUrl("https://rxresu.me/other/pic.jpg", appUrl)).toBe(""); - }); - - it("rejects invalid URLs, invalid app URLs, and credentialed URLs", () => { - expect(sanitizeResumePictureUrl("not-a-url", appUrl)).toBe(""); - expect(sanitizeResumePictureUrl("ftp://rxresu.me/uploads/p.jpg", appUrl)).toBe(""); - expect(sanitizeResumePictureUrl("https://user:pass@rxresu.me/uploads/p.jpg", appUrl)).toBe(""); - expect(sanitizeResumePictureUrl("https://rxresu.me/uploads/p.jpg", "not-a-url")).toBe(""); - }); -}); - -describe("parse helpers", () => { - it("parseUrl returns URL object for valid URLs and null for invalid input", () => { - expect(parseUrl("https://example.com/path")?.hostname).toBe("example.com"); - expect(parseUrl("invalid url")).toBeNull(); - }); - - it("parseAllowedHostList normalizes host entries and handles empty input", () => { - const set = parseAllowedHostList(" API.OpenAI.com , https://gateway.ai.vercel.com ,,api.openai.com "); - expect(set.has("api.openai.com")).toBe(true); - expect(set.has("https://gateway.ai.vercel.com")).toBe(true); - expect(set.size).toBe(2); - - expect(parseAllowedHostList(undefined).size).toBe(0); - expect(parseAllowedHostList("").size).toBe(0); - }); -}); diff --git a/src/utils/url-security.ts b/src/utils/url-security.ts deleted file mode 100644 index a2b193835..000000000 --- a/src/utils/url-security.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { isIP } from "node:net"; - -function normalizeHostname(hostname: string) { - return hostname.trim().toLowerCase(); -} - -function stripIpv6Brackets(hostname: string): string { - return hostname.replace(/^\[/, "").replace(/\]$/, ""); -} - -function isLoopbackOrLocalHostname(hostname: string) { - const normalized = normalizeHostname(hostname); - return ( - normalized === "localhost" || normalized === "::1" || normalized === "[::1]" || normalized.endsWith(".localhost") - ); -} - -function isPrivateIPv4(hostname: string) { - const [first = 0, second = 0] = hostname.split(".").map((part) => Number.parseInt(part, 10)); - if (Number.isNaN(first) || Number.isNaN(second)) return false; - - if (first === 10) return true; - if (first === 127) return true; - if (first === 169 && second === 254) return true; - if (first === 172 && second >= 16 && second <= 31) return true; - if (first === 192 && second === 168) return true; - if (first === 0) return true; - - return false; -} - -function isPrivateIPv6(hostname: string) { - const normalized = stripIpv6Brackets(normalizeHostname(hostname)); - return ( - normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:") - ); -} - -export function isPrivateOrLoopbackHost(hostname: string) { - const normalized = stripIpv6Brackets(normalizeHostname(hostname)); - if (isLoopbackOrLocalHostname(normalized)) return true; - - const ipVersion = isIP(normalized); - if (ipVersion === 4) return isPrivateIPv4(normalized); - if (ipVersion === 6) return isPrivateIPv6(normalized); - - return false; -} - -function isOAuthLoopbackRedirectHost(hostname: string) { - const normalized = stripIpv6Brackets(normalizeHostname(hostname)); - return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; -} - -export function parseUrl(input: string) { - try { - return new URL(input); - } catch { - return null; - } -} - -export function parseAllowedHostList(value?: string) { - if (!value) return new Set(); - - const hosts = value - .split(",") - .map((entry) => entry.trim().toLowerCase()) - .filter(Boolean); - - return new Set(hosts); -} - -export function isAllowedExternalUrl(input: string, allowedHosts: Set) { - const parsed = parseUrl(input); - if (!parsed) return false; - if (parsed.protocol !== "https:") return false; - if (parsed.username || parsed.password) return false; - if (isPrivateOrLoopbackHost(parsed.hostname)) return false; - - const hostname = normalizeHostname(parsed.hostname); - if (allowedHosts.has(hostname)) return true; - - const origin = parsed.origin.toLowerCase(); - return allowedHosts.has(origin); -} - -export function isAllowedOAuthRedirectUri(input: string, trustedOrigins: string[], allowedHosts: Set) { - const parsed = parseUrl(input); - if (!parsed) return false; - if (parsed.username || parsed.password) return false; - if (parsed.hash) return false; - - const origin = parsed.origin.toLowerCase(); - const hostname = normalizeHostname(parsed.hostname); - - if (parsed.protocol === "http:") return isOAuthLoopbackRedirectHost(hostname); - if (parsed.protocol !== "https:") return false; - if (isPrivateOrLoopbackHost(hostname)) return false; - - if (trustedOrigins.includes(origin)) return true; - if (allowedHosts.has(origin)) return true; - return allowedHosts.has(hostname); -} - -export function sanitizeResumePictureUrl(url: string, appUrl: string) { - if (!url) return ""; - if (url.startsWith("/uploads/")) return url; - - const parsed = parseUrl(url); - if (!parsed) return ""; - if (parsed.username || parsed.password) return ""; - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return ""; - - const app = parseUrl(appUrl); - if (!app) return ""; - - if (parsed.origin !== app.origin) return ""; - if (!parsed.pathname.startsWith("/uploads/")) return ""; - - return `${parsed.pathname}${parsed.search}${parsed.hash}`; -} diff --git a/src/utils/url.test.ts b/src/utils/url.test.ts deleted file mode 100644 index eaad11c5d..000000000 --- a/src/utils/url.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { createUrl } from "./url"; - -describe("createUrl", () => { - it("returns empty strings for undefined url", () => { - expect(createUrl()).toEqual({ url: "", label: "" }); - expect(createUrl(undefined)).toEqual({ url: "", label: "" }); - }); - - it("uses url as label when label is not provided", () => { - expect(createUrl("https://example.com")).toEqual({ - url: "https://example.com", - label: "https://example.com", - }); - }); - - it("uses provided label", () => { - expect(createUrl("https://example.com", "Example")).toEqual({ - url: "https://example.com", - label: "Example", - }); - }); -}); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts deleted file mode 100644 index dbd49023e..000000000 --- a/src/vite-env.d.ts +++ /dev/null @@ -1,57 +0,0 @@ -/// - -declare const __APP_VERSION__: string; - -declare module "*.css"; -declare module "@fontsource/*" {} -declare module "@fontsource-variable/*" {} - -declare namespace NodeJS { - interface ProcessEnv { - // Basics - PORT: string; - APP_URL: string; - PRINTER_APP_URL?: string; - - // Authentication - AUTH_SECRET: string; - - // Printer - PRINTER_ENDPOINT?: string; - - // Database - DATABASE_URL: string; - - // Social Auth (Google) - GOOGLE_CLIENT_ID?: string; - GOOGLE_CLIENT_SECRET?: string; - - // Social Auth (GitHub) - GITHUB_CLIENT_ID?: string; - GITHUB_CLIENT_SECRET?: string; - - // Storage (Optional) - S3_ACCESS_KEY_ID?: string; - S3_SECRET_ACCESS_KEY?: string; - S3_REGION?: string; - S3_ENDPOINT?: string; - S3_BUCKET?: string; - S3_FORCE_PATH_STYLE?: string | boolean; - - // Custom OAuth Provider - OAUTH_PROVIDER_NAME?: string; - OAUTH_CLIENT_ID?: string; - OAUTH_CLIENT_SECRET?: string; - OAUTH_DISCOVERY_URL?: string; - OAUTH_AUTHORIZATION_URL?: string; - OAUTH_TOKEN_URL?: string; - OAUTH_USER_INFO_URL?: string; - OAUTH_SCOPES?: string; - - // Feature Flags - FLAG_DEBUG_PRINTER: string | boolean; - FLAG_DISABLE_SIGNUPS: string | boolean; - FLAG_DISABLE_EMAIL_AUTH: string | boolean; - FLAG_DISABLE_IMAGE_PROCESSING: string | boolean; - } -} diff --git a/tsconfig.json b/tsconfig.json index 654eae826..66de57db4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,32 +1,3 @@ { - "include": ["**/*.ts", "**/*.tsx"], - "compilerOptions": { - "target": "ES2022", - "jsx": "react-jsx", - "module": "ESNext", - "types": ["vite/client"], - "lib": ["ES2022", "DOM", "DOM.Iterable"], - - /* Bundler mode */ - "noEmit": true, - "moduleResolution": "bundler", - "esModuleInterop": true, - "verbatimModuleSyntax": false, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowImportingTsExtensions": true, - - /* Linting */ - "strict": true, - "skipLibCheck": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": false, - - /* Paths */ - "paths": { - "@/*": ["./src/*"] - } - } + "extends": "@reactive-resume/config/tsconfig.base.json" } diff --git a/turbo.json b/turbo.json new file mode 100644 index 000000000..39dff1f25 --- /dev/null +++ b/turbo.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://turbo.build/schema.json", + "ui": "tui", + "globalEnv": [ + "PORT", + "APP_URL", + "DATABASE_URL", + "AUTH_SECRET", + "BETTER_AUTH_API_KEY", + "GOOGLE_CLIENT_ID", + "GOOGLE_CLIENT_SECRET", + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", + "LINKEDIN_CLIENT_ID", + "LINKEDIN_CLIENT_SECRET", + "OAUTH_PROVIDER_NAME", + "OAUTH_CLIENT_ID", + "OAUTH_CLIENT_SECRET", + "OAUTH_DISCOVERY_URL", + "OAUTH_AUTHORIZATION_URL", + "OAUTH_TOKEN_URL", + "OAUTH_USER_INFO_URL", + "OAUTH_SCOPES", + "OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS", + "SMTP_HOST", + "SMTP_PORT", + "SMTP_USER", + "SMTP_PASS", + "SMTP_FROM", + "SMTP_SECURE", + "S3_ACCESS_KEY_ID", + "S3_SECRET_ACCESS_KEY", + "S3_REGION", + "S3_ENDPOINT", + "S3_BUCKET", + "S3_FORCE_PATH_STYLE", + "FLAG_DISABLE_SIGNUPS", + "FLAG_DISABLE_EMAIL_AUTH", + "FLAG_DISABLE_IMAGE_PROCESSING", + "GOOGLE_CLOUD_API_KEY", + "CROWDIN_PROJECT_ID", + "CROWDIN_API_TOKEN" + ], + "tasks": { + "source": { + "dependsOn": ["^source"] + }, + "build": { + "dependsOn": ["source"], + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/.env*"], + "outputs": ["dist/**", ".output/**", ".vercel/**"] + }, + "lint": { + "dependsOn": ["^lint"] + }, + "typecheck": { + "dependsOn": ["source"] + }, + "test": { + "dependsOn": ["source"] + }, + "test:coverage": { + "dependsOn": ["source"] + }, + "test:ci": { + "dependsOn": ["source"] + }, + "test:agent": { + "dependsOn": ["source"] + }, + "dev": { + "cache": false, + "persistent": true + }, + "start": { + "cache": false, + "persistent": true + }, + "db:generate": { + "cache": false + }, + "db:migrate": { + "cache": false + }, + "db:studio": { + "cache": false, + "persistent": true + }, + "lingui:extract": { + "cache": false + } + } +} diff --git a/vite.config.ts b/vite.config.ts deleted file mode 100644 index 8fc019f75..000000000 --- a/vite.config.ts +++ /dev/null @@ -1,296 +0,0 @@ -import type { PluginOption } from "vite-plus"; -import type { BuiltinReporters } from "vite-plus/test/reporters"; - -import { lingui } from "@lingui/vite-plugin"; -import babel from "@rolldown/plugin-babel"; -import tailwindcss from "@tailwindcss/vite"; -import { tanstackStart } from "@tanstack/react-start/plugin/vite"; -import viteReact from "@vitejs/plugin-react"; -import { nitro } from "nitro/vite"; -import { VitePWA } from "vite-plugin-pwa"; -import { defineConfig } from "vite-plus"; - -const isVitest = Boolean(process.env.VITEST); - -const testReporters: BuiltinReporters[] = []; - -if (process.env.GITHUB_ACTIONS) { - testReporters.push("github-actions"); -} else if (process.env.AGENT) { - testReporters.push("agent"); -} else { - testReporters.push("dot"); -} - -const plugins: PluginOption[] = [tanstackStart({ router: { semicolons: true, quoteStyle: "double" } }), viteReact()]; - -if (!isVitest) { - plugins.push( - lingui(), - tailwindcss(), - nitro({ - plugins: ["plugins/1.migrate.ts"], - rolldownConfig: { - treeshake: { - moduleSideEffects: (id) => { - if (id.includes("unenv/polyfill/")) return true; - if (id.includes("reflect-metadata/Reflect")) return true; - if (id.includes("node-fetch-native/polyfill")) return true; - return false; - }, - }, - }, - }), - babel({ plugins: ["@lingui/babel-plugin-lingui-macro"] }), - VitePWA({ - outDir: "public", - useCredentials: true, - injectRegister: false, - includeAssets: ["**/*"], - registerType: "autoUpdate", - workbox: { - skipWaiting: true, - clientsClaim: true, - globPatterns: ["**/*"], - globIgnores: ["**/manifest.webmanifest"], - maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10mb - navigateFallback: null, // Disable navigation fallback for SSR - }, - manifest: { - name: "Reactive Resume", - short_name: "Reactive Resume", - description: "A free and open-source resume builder.", - id: "/?source=pwa", - start_url: "/?source=pwa", - display: "standalone", - orientation: "portrait", - theme_color: "#09090B", - background_color: "#09090B", - icons: [ - { - src: "favicon.ico", - sizes: "128x128", - type: "image/x-icon", - }, - { - src: "pwa-64x64.png", - sizes: "64x64", - type: "image/png", - }, - { - src: "pwa-192x192.png", - sizes: "192x192", - type: "image/png", - }, - { - src: "pwa-512x512.png", - sizes: "512x512", - type: "image/png", - purpose: "any", - }, - { - src: "maskable-icon-512x512.png", - sizes: "512x512", - type: "image/png", - purpose: "maskable", - }, - ], - screenshots: [ - { - src: "screenshots/web/1-landing-page.webp", - sizes: "1920x1080 any", - type: "image/webp", - form_factor: "wide", - label: "Landing Page", - }, - { - src: "screenshots/web/2-resume-dashboard.webp", - sizes: "1920x1080 any", - type: "image/webp", - form_factor: "wide", - label: "Resume Dashboard", - }, - { - src: "screenshots/web/3-builder-screen.webp", - sizes: "1920x1080 any", - type: "image/webp", - form_factor: "wide", - label: "Builder Screen", - }, - { - src: "screenshots/web/4-template-gallery.webp", - sizes: "1920x1080 any", - type: "image/webp", - form_factor: "wide", - label: "Template Gallery", - }, - { - src: "screenshots/mobile/1-landing-page.webp", - sizes: "1284x2778 any", - type: "image/webp", - form_factor: "narrow", - label: "Landing Page", - }, - { - src: "screenshots/mobile/2-resume-dashboard.webp", - sizes: "1284x2778 any", - type: "image/webp", - form_factor: "narrow", - label: "Resume Dashboard", - }, - { - src: "screenshots/mobile/3-builder-screen.webp", - sizes: "1284x2778 any", - type: "image/webp", - form_factor: "narrow", - label: "Builder Screen", - }, - { - src: "screenshots/mobile/4-template-gallery.webp", - sizes: "1284x2778 any", - type: "image/webp", - form_factor: "narrow", - label: "Template Gallery", - }, - ], - categories: [ - "ai", - "builder", - "business", - "career", - "cv", - "editor", - "free", - "generator", - "job-search", - "multilingual", - "open-source", - "privacy", - "productivity", - "resume", - "self-hosted", - "templates", - "utilities", - "writing", - ], - }, - }), - ); -} - -const config = defineConfig({ - plugins, - - run: { cache: true }, - - staged: { - "*": "vp check --fix", - }, - - fmt: { - printWidth: 120, - ignorePatterns: ["webfontlist.json", "routeTree.gen.ts", "docs/changelog/index.mdx"], - sortPackageJson: { - sortScripts: true, - }, - sortTailwindcss: { - stylesheet: "./src/styles/globals.css", - functions: ["clsx", "cva", "cn"], - }, - sortImports: { - groups: [ - "type-import", - ["value-builtin", "value-external"], - "type-internal", - "value-internal", - ["type-parent", "type-sibling", "type-index"], - ["value-parent", "value-sibling", "value-index"], - "unknown", - ], - }, - }, - - lint: { - env: { builtin: true }, - ignorePatterns: ["webfontlist.json", "routeTree.gen.ts"], - options: { typeAware: true, typeCheck: true }, - settings: { - react: { - version: "19", - linkComponents: ["Link"], - }, - }, - plugins: [ - "eslint", - "import", - "jest", - "jsdoc", - "jsx-a11y", - "node", - "oxc", - "promise", - "react-perf", - "react", - "typescript", - "unicorn", - "vitest", - ], - rules: { - "react/exhaustive-deps": "off", - "jest/no-conditional-expect": "off", - "jest/require-to-throw-message": "off", - "typescript/consistent-type-imports": "error", - }, - }, - - define: { - __APP_VERSION__: JSON.stringify(process.env.npm_package_version), - }, - - resolve: { - tsconfigPaths: true, - }, - - test: { - environment: "happy-dom", - reporters: testReporters, - coverage: { - provider: "v8", - reporter: ["text", "html", "lcov", "json-summary"], - reportsDirectory: "./coverage", - include: [ - "src/utils/**/*.{ts,tsx}", - "src/hooks/**/*.{ts,tsx}", - "src/integrations/**/*.{ts,tsx}", - "src/components/**/*.{ts,tsx}", - "src/schema/**/*.{ts,tsx}", - "src/dialogs/**/*.{ts,tsx}", - ], - exclude: ["**/*.test.{ts,tsx}", "**/*.d.ts", "**/node_modules/**", "src/components/ui/**"], - thresholds: { - statements: 32, - branches: 37, - functions: 20, - lines: 31, - }, - }, - }, - - build: { - sourcemap: true, - chunkSizeWarningLimit: 10 * 1024, // 10mb - }, - - server: { - host: true, - port: 3000, - strictPort: true, - allowedHosts: true, - hmr: { - host: "localhost", - port: 3000, - }, - }, -}); - -export default config; diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 000000000..f149f27ae --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/vitest.shared.ts b/vitest.shared.ts new file mode 100644 index 000000000..ac962b002 --- /dev/null +++ b/vitest.shared.ts @@ -0,0 +1,44 @@ +import type { ViteUserConfig } from "vitest/config"; +import type { VitestEnvironment } from "vitest/node"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +const workspaceRoot = fileURLToPath(new URL(".", import.meta.url)); +const setupFile = fileURLToPath(new URL("./vitest.setup.ts", import.meta.url)); + +type VitestProjectOptions = { + name: string; + dirname: string; + environment?: VitestEnvironment; + plugins?: ViteUserConfig["plugins"]; +}; + +export const createVitestProjectConfig = ({ + name, + dirname, + environment = "node", + plugins = [], +}: VitestProjectOptions) => + defineConfig({ + root: dirname, + envDir: workspaceRoot, + resolve: { tsconfigPaths: true }, + plugins, + test: { + name, + environment, + setupFiles: [setupFile], + include: ["src/**/*.{test,spec}.?(c|m)[jt]s?(x)"], + exclude: ["node_modules", "dist", ".output", "coverage", "reports"], + pool: "forks", + passWithNoTests: true, + coverage: { + provider: "v8", + reportsDirectory: "coverage", + reporter: ["text", "text-summary", "json-summary", "json", "lcov", "html"], + include: ["src/**/*.{ts,tsx}"], + exclude: ["src/**/*.{test,spec}.*", "src/**/*.d.ts", "src/routeTree.gen.ts"], + reportOnFailure: true, + }, + }, + });