diff --git a/.env.example b/.env.example index da15b741..b27566bd 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,7 @@ SESSION_SECRET="super-duper-s3cret" HONEYPOT_SECRET="super-duper-s3cret" INTERNAL_COMMAND_TOKEN="some-made-up-token" RESEND_API_KEY="re_blAh_blaHBlaHblahBLAhBlAh" +RESEND_EMAIL_ADDRESS="email@website.com" SENTRY_DSN="your-dsn" # the mocks and some code rely on these two being prefixed with "MOCK_" @@ -15,3 +16,9 @@ GITHUB_CLIENT_SECRET="MOCK_GITHUB_CLIENT_SECRET" GITHUB_TOKEN="MOCK_GITHUB_TOKEN" FLY_APP_NAME="fly-app-name-1234" + +# set this to false to prevent search engines from indexing the website +# default to allow indexing for seo safety +# set false for staging via fly secrets +# https://github.com/epicweb-dev/epic-stack/blob/main/docs/deployment.md +ALLOW_INDEXING="true" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 986ed777..1d2c56f0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -136,48 +136,46 @@ jobs: path: playwright-report/ retention-days: 30 - # uncomment when I feel that fly deploy is stable enough - # deploy: - # name: 🚀 Deploy - # runs-on: ubuntu-22.04 - # needs: [lint, typecheck, vitest, playwright] - # # only build/deploy main branch on pushes - # if: - # ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && - # github.event_name == 'push' }} - - # steps: - # - name: ⬇️ Checkout repo - # uses: actions/checkout@v3 - - # - name: 👀 Read app name - # uses: SebRollen/toml-action@v1.0.2 - # id: app_name - # with: - # file: 'fly.toml' - # field: 'app' - - # # move Dockerfile to root - # - name: 🚚 Move Dockerfile - # run: | - # mv ./other/Dockerfile ./Dockerfile - # mv ./other/.dockerignore ./.dockerignore - - # - name: 🎈 Setup Fly - # uses: superfly/flyctl-actions/setup-flyctl@v1.4 - - # - name: 🚀 Deploy Staging - # if: ${{ github.ref == 'refs/heads/dev' }} - # run: - # flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} - # --app ${{ steps.app_name.outputs.value }}-staging - # env: - # FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - - # - name: 🚀 Deploy Production - # if: ${{ github.ref == 'refs/heads/main' }} - # run: - # flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} - # --build-secret SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} - # env: - # FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + uncomment when I feel that fly deploy is stable enough + deploy: + name: 🚀 Deploy + runs-on: ubuntu-22.04 + needs: [lint, typecheck, vitest, playwright] + # only build/deploy main branch on pushes + if: ${{ github.event_name == 'push' }} + + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + + - name: 👀 Read app name + uses: SebRollen/toml-action@v1.2.0 + id: app_name + with: + file: 'fly.toml' + field: 'app' + + # move Dockerfile to root + - name: 🚚 Move Dockerfile + run: | + mv ./other/Dockerfile ./Dockerfile + mv ./other/.dockerignore ./.dockerignore + + - name: 🎈 Setup Fly + uses: superfly/flyctl-actions/setup-flyctl@1.5 + + - name: 🚀 Deploy Staging + if: ${{ github.ref == 'refs/heads/dev' }} + run: + flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} + --app ${{ steps.app_name.outputs.value }}-staging + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + + - name: 🚀 Deploy Production + if: ${{ github.ref == 'refs/heads/main' }} + run: + flyctl deploy --remote-only --build-arg COMMIT_SHA=${{ github.sha }} + --build-secret SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/app/root.tsx b/app/root.tsx index 11a7c411..95997431 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -107,7 +107,7 @@ export async function loader({ request }: LoaderFunctionArgs) { where: { id: userId }, }), { timings, type: 'find user', desc: 'find user in root' }, - ) + ) : null if (userId && !user) { console.info('something weird happened') @@ -179,11 +179,13 @@ function Document({ children, nonce, theme = 'light', + allowIndexing = true, env = {}, }: { children: React.ReactNode nonce: string theme?: Theme + allowIndexing?: boolean env?: Record }) { return ( @@ -193,6 +195,9 @@ function Document({ + {allowIndexing ? null : ( + + )} @@ -240,10 +245,16 @@ function App() { const data = useLoaderData() const nonce = useNonce() const theme = useTheme() + const allowIndexing = data.ENV.ALLOW_INDEXING !== 'false' useToast(data.toast) return ( - + diff --git a/app/utils/auth.server.ts b/app/utils/auth.server.ts index 911dfa16..e80b1f5a 100644 --- a/app/utils/auth.server.ts +++ b/app/utils/auth.server.ts @@ -130,7 +130,9 @@ export async function signup({ email: email.toLowerCase(), username: username.toLowerCase(), name, - roles: { connect: { name: 'user' } }, + roles: { + connect: [{ name: 'admin' }, { name: 'user' }], + }, password: { create: { hash: hashedPassword, diff --git a/app/utils/email.server.ts b/app/utils/email.server.ts index f62a369e..ab42b90c 100644 --- a/app/utils/email.server.ts +++ b/app/utils/email.server.ts @@ -31,7 +31,7 @@ export async function sendEmail({ | { html: string; text: string; react?: never } | { react: ReactElement; html?: never; text?: never } )) { - const from = 'hello@epicstack.dev' + const from = process.env.RESEND_EMAIL_ADDRESS const email = { from, diff --git a/app/utils/env.server.ts b/app/utils/env.server.ts index d713e5e5..e59eb466 100644 --- a/app/utils/env.server.ts +++ b/app/utils/env.server.ts @@ -16,6 +16,7 @@ const schema = z.object({ GITHUB_CLIENT_ID: z.string().default('MOCK_GITHUB_CLIENT_ID'), GITHUB_CLIENT_SECRET: z.string().default('MOCK_GITHUB_CLIENT_SECRET'), GITHUB_TOKEN: z.string().default('MOCK_GITHUB_TOKEN'), + ALLOW_INDEXING: z.enum(['true', 'false']).optional(), }) declare global { @@ -50,6 +51,7 @@ export function getEnv() { return { MODE: process.env.NODE_ENV, SENTRY_DSN: process.env.SENTRY_DSN, + ALLOW_INDEXING: process.env.ALLOW_INDEXING, } } diff --git a/fly.toml b/fly.toml index bc8a2247..f0576b8d 100644 --- a/fly.toml +++ b/fly.toml @@ -3,6 +3,7 @@ primary_region = "bos" kill_signal = "SIGINT" kill_timeout = 5 processes = [ ] +swap_size_mb = 512 [experimental] allowed_public_ports = [ ] diff --git a/package.json b/package.json index 03661cb3..608253eb 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,14 @@ "typecheck": "tsc", "typecheck:watch": "tsc -w", "validate": "run-p \"test -- --run\" lint typecheck test:e2e:run", + "fly:status:prod": "npm run fly:status -- --env=production", + "fly:status:staging": "npm run fly:status -- --env=staging", "fly:status": "./scripts/fly.io/app-status.sh", + "fly:console:prisma:studio:prod": "npm run fly:console:prisma:studio -- --env=production", + "fly:console:prisma:studio:staging": "npm run fly:console:prisma:studio -- --env=staging", "fly:console:prisma:studio": "./scripts/fly.io/app-console-prisma-studio.sh", + "fly:console:prisma:studio:proxy:prod": "npm run fly:console:prisma:studio:proxy -- --env=production", + "fly:console:prisma:studio:proxy:staging": "npm run fly:console:prisma:studio:proxy -- --env=staging", "fly:console:prisma:studio:proxy": "./scripts/fly.io/app-console-prisma-studio-proxy.sh", "prepare": "husky install" }, diff --git a/scripts/fly.io/app-console-prisma-studio-proxy.sh b/scripts/fly.io/app-console-prisma-studio-proxy.sh index 1a1104f3..07507eb9 100755 --- a/scripts/fly.io/app-console-prisma-studio-proxy.sh +++ b/scripts/fly.io/app-console-prisma-studio-proxy.sh @@ -9,6 +9,50 @@ # run proxy to prisma studio on fly app to local port (separate terminal) # npm run fly:app:console:prisma:studio:proxy +# Check if the script is running in production environment +if [ "$NODE_ENV" = "production" ]; then + echo "This script should not run in production." + exit 1 +fi + +# Default environment +ENV="development" + +# Parse arguments +for ARG in "$@" +do + case $ARG in + --env=*) + ENV="${ARG#*=}" + shift + ;; + esac +done + +# Source environment variables source .env +# Check if the environment variable is set +if [ -z "$FLY_APP_NAME" ]; then + echo "Error: FLY_APP_NAME environment variable is not set." + exit 1 +fi + +# Modify the app name for staging environment +if [ "$ENV" = "staging" ]; then + FLY_APP_NAME="${FLY_APP_NAME}-staging" +elif [ "$ENV" != "production" ]; then + echo "Unknown environment: $ENV" + exit 1 +fi + +# Run the Fly.io proxy command fly proxy 5556:5555 --app $FLY_APP_NAME + +# FYI closing the proxy will display a warning: +# "Error: ssh shell: session forcibly closed; the remote process may still be running" +# don't worry :D +# https://community.fly.io/t/does-fly-know-when-my-app-decides-to-gracefully-shutdown/19132 +# the comments from this post seem to suggest the healthcheck fail will close the connection +# https://fly.io/docs/reference/configuration/#services-tcp_checks +# here is the doc to confirm :D \ No newline at end of file diff --git a/scripts/fly.io/app-console-prisma-studio.sh b/scripts/fly.io/app-console-prisma-studio.sh index 98e42409..776da176 100755 --- a/scripts/fly.io/app-console-prisma-studio.sh +++ b/scripts/fly.io/app-console-prisma-studio.sh @@ -9,6 +9,42 @@ # run proxy to prisma studio on fly app to local port (separate terminal) # npm run fly:app:console:prisma:studio:proxy +# Check if the script is running in production environment +if [ "$NODE_ENV" = "production" ]; then + echo "This script should not run in production." + exit 1 +fi + +# Default environment +ENV="development" + +# Parse arguments +for ARG in "$@" +do + case $ARG in + --env=*) + ENV="${ARG#*=}" + shift + ;; + esac +done + +# Source environment variables source .env +# Check if the environment variable is set +if [ -z "$FLY_APP_NAME" ]; then + echo "Error: FLY_APP_NAME environment variable is not set." + exit 1 +fi + +# Modify the app name for staging environment +if [ "$ENV" = "staging" ]; then + FLY_APP_NAME="${FLY_APP_NAME}-staging" +elif [ "$ENV" != "production" ]; then + echo "Unknown environment: $ENV" + exit 1 +fi + +# Run the Fly.io ssh console command fly ssh console -C "npm run prisma:studio" --app $FLY_APP_NAME diff --git a/scripts/fly.io/app-status.sh b/scripts/fly.io/app-status.sh index f9a2ba94..23790df2 100755 --- a/scripts/fly.io/app-status.sh +++ b/scripts/fly.io/app-status.sh @@ -4,6 +4,42 @@ # to give permission to execute the file locally run: # chmod +x scripts/fly.io/app-status.sh +# Check if the script is running in production environment +if [ "$NODE_ENV" = "production" ]; then + echo "This script should not run in production." + exit 1 +fi + +# Default environment +ENV="development" + +# Parse arguments +for ARG in "$@" +do + case $ARG in + --env=*) + ENV="${ARG#*=}" + shift + ;; + esac +done + +# Source environment variables source .env +# Check if the environment variable is set +if [ -z "$FLY_APP_NAME" ]; then + echo "Error: FLY_APP_NAME environment variable is not set." + exit 1 +fi + +# Modify the app name for staging environment +if [ "$ENV" = "staging" ]; then + FLY_APP_NAME="${FLY_APP_NAME}-staging" +elif [ "$ENV" != "production" ]; then + echo "Unknown environment: $ENV" + exit 1 +fi + +# Run the Fly.io status command fly status --app $FLY_APP_NAME \ No newline at end of file diff --git a/server/index.ts b/server/index.ts index 558f5284..543a0e8f 100644 --- a/server/index.ts +++ b/server/index.ts @@ -17,6 +17,7 @@ installGlobals() const MODE = process.env.NODE_ENV ?? 'development' const IS_PROD = MODE === 'production' const IS_DEV = MODE === 'development' +const ALLOW_INDEXING = process.env.ALLOW_INDEXING !== 'false' const createRequestHandler = IS_PROD ? Sentry.wrapExpressCreateRequestHandler(_createRequestHandler) @@ -208,6 +209,13 @@ async function getBuild() { return build as unknown as ServerBuild } +if (!ALLOW_INDEXING) { + app.use((_, res, next) => { + res.set('X-Robots-Tag', 'noindex, nofollow') + next() + }) +} + app.all( '*', createRequestHandler({ diff --git a/tests/e2e/onboarding.test.ts b/tests/e2e/onboarding.test.ts index 35572539..e0cb2ee9 100644 --- a/tests/e2e/onboarding.test.ts +++ b/tests/e2e/onboarding.test.ts @@ -4,6 +4,7 @@ import { prisma } from '#app/utils/db.server.ts' import { readEmail } from '#tests/mocks/utils.ts' import { createUser, expect, test as base } from '#tests/playwright-utils.ts' +const RESEND_EMAIL_ADDRESS = process.env.RESEND_EMAIL_ADDRESS const URL_REGEX = /(?https?:\/\/[^\s$.?#].[^\s]*)/ const CODE_REGEX = /Here's your verification code: (?[\d\w]+)/ function extractUrl(text: string) { @@ -61,7 +62,7 @@ test('onboarding with link', async ({ page, getOnboardingData }) => { const email = await readEmail(onboardingData.email) invariant(email, 'Email not found') expect(email.to).toBe(onboardingData.email.toLowerCase()) - expect(email.from).toBe('hello@epicstack.dev') + expect(email.from).toBe(RESEND_EMAIL_ADDRESS) expect(email.subject).toMatch(/welcome/i) const onboardingUrl = extractUrl(email.text) invariant(onboardingUrl, 'Onboarding URL not found') @@ -121,7 +122,7 @@ test('onboarding with a short code', async ({ page, getOnboardingData }) => { const email = await readEmail(onboardingData.email) invariant(email, 'Email not found') expect(email.to).toBe(onboardingData.email.toLowerCase()) - expect(email.from).toBe('hello@epicstack.dev') + expect(email.from).toBe(RESEND_EMAIL_ADDRESS) expect(email.subject).toMatch(/welcome/i) const codeMatch = email.text.match(CODE_REGEX) const code = codeMatch?.groups?.code @@ -168,7 +169,7 @@ test('reset password with a link', async ({ page, insertNewUser }) => { invariant(email, 'Email not found') expect(email.subject).toMatch(/password reset/i) expect(email.to).toBe(user.email.toLowerCase()) - expect(email.from).toBe('hello@epicstack.dev') + expect(email.from).toBe(RESEND_EMAIL_ADDRESS) const resetPasswordUrl = extractUrl(email.text) invariant(resetPasswordUrl, 'Reset password URL not found') await page.goto(resetPasswordUrl) @@ -226,7 +227,7 @@ test('reset password with a short code', async ({ page, insertNewUser }) => { invariant(email, 'Email not found') expect(email.subject).toMatch(/password reset/i) expect(email.to).toBe(user.email) - expect(email.from).toBe('hello@epicstack.dev') + expect(email.from).toBe(RESEND_EMAIL_ADDRESS) const codeMatch = email.text.match(CODE_REGEX) const code = codeMatch?.groups?.code invariant(code, 'Reset Password code not found')