diff --git a/README.md b/README.md index efa3d4fc42e..4c1ffc26328 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ There are three projects which can be run individually. ### Web ``` +cp apps/web/.env.local.example apps/web/.env.local yarn workspace @app/web dev ``` diff --git a/apps/web/app/(basenames)/api/basenames/avatar/ipfsUpload/route.ts b/apps/web/app/(basenames)/api/basenames/avatar/ipfsUpload/route.ts index d807ec18541..64a1ea30401 100644 --- a/apps/web/app/(basenames)/api/basenames/avatar/ipfsUpload/route.ts +++ b/apps/web/app/(basenames)/api/basenames/avatar/ipfsUpload/route.ts @@ -1,4 +1,4 @@ -import { pinata } from 'apps/web/src/utils/pinata'; +import { getPinata } from 'apps/web/src/utils/pinata'; import { isDevelopment } from 'libs/base-ui/constants'; import { NextResponse, NextRequest } from 'next/server'; @@ -50,6 +50,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'File is too large' }, { status: 500 }); // Upload + const pinata = getPinata(); const uploadData = await pinata.upload.file(file, { groupId: '765ab5e4-0bc3-47bb-9d6a-35b308291009', metadata: { @@ -58,6 +59,7 @@ export async function POST(request: NextRequest) { }); return NextResponse.json(uploadData, { status: 200 }); } catch (e) { - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + const errorMessage = e instanceof Error ? e.message : 'Internal Server Error'; + return NextResponse.json({ error: errorMessage }, { status: 500 }); } } diff --git a/apps/web/app/api/cloudinaryUrl/route.ts b/apps/web/app/api/cloudinaryUrl/route.ts index 24cdc46f14d..564f1ab786d 100644 --- a/apps/web/app/api/cloudinaryUrl/route.ts +++ b/apps/web/app/api/cloudinaryUrl/route.ts @@ -2,13 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { v2 as cloudinary } from 'cloudinary'; import { createHash } from 'crypto'; import { logger } from 'apps/web/src/utils/logger'; - -// Configure Cloudinary -cloudinary.config({ - cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME, - api_key: process.env.CLOUDINARY_API_KEY, - api_secret: process.env.CLOUDINARY_API_SECRET, -}); +import { requireEnv } from 'apps/web/src/utils/env'; const folderName = 'base-org-uploads'; @@ -86,6 +80,13 @@ async function getCloudinaryMediaUrl({ export async function POST(request: NextRequest) { try { + // Only validate when this API route is actually invoked. + cloudinary.config({ + cloud_name: requireEnv('NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME', { feature: 'Cloudinary' }), + api_key: requireEnv('CLOUDINARY_API_KEY', { feature: 'Cloudinary' }), + api_secret: requireEnv('CLOUDINARY_API_SECRET', { feature: 'Cloudinary' }), + }); + const body = (await request.json()) as CloudinaryMediaUrlRequest; const { media, width } = body; @@ -105,6 +106,7 @@ export async function POST(request: NextRequest) { } } catch (error) { logger.error('Error processing Cloudinary URL:', error); - return NextResponse.json({ error: 'Failed to process Cloudinary URL' }, { status: 500 }); + const errorMessage = error instanceof Error ? error.message : 'Failed to process Cloudinary URL'; + return NextResponse.json({ error: errorMessage }, { status: 500 }); } } diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 1b3be0840f3..830fb594ca2 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/package.json b/apps/web/package.json index ba41491730b..4ac3a8c949c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,17 +3,18 @@ "version": "0.0.0", "private": true, "scripts": { + "check-env": "node ./scripts/check-env.js", "start": "node --require ./tracer/initialize.js ./node_modules/.bin/next start", "build": "next build", "test": "jest", "analyze": "ANALYZE=true next build", - "dev": "node --require ./tracer/initialize.js ./node_modules/.bin/next dev", + "dev": "yarn check-env && node --require ./tracer/initialize.js ./node_modules/.bin/next dev", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:debug": "playwright test --debug", "test:e2e:headed": "playwright test --headed", "e2e:metamask:prepare": "node ../../node_modules/@coinbase/onchaintestkit/src/cli/prepare-metamask.mjs", - "dev:million": "cross-env MILLION_LINT=true node --require ./tracer/initialize.js ./node_modules/.bin/next dev", + "dev:million": "yarn check-env && cross-env MILLION_LINT=true node --require ./tracer/initialize.js ./node_modules/.bin/next dev", "lint": "next lint", "update-contributors": "node ./scripts/updateContributors.js && npx prettier ./src/components/CoreContributors/CoreContributors.json -w", "fetch-mirror-blog": "node ./scripts/pullLatestBlogPosts.js" diff --git a/apps/web/scripts/check-env.js b/apps/web/scripts/check-env.js new file mode 100644 index 00000000000..224bbc5a5cb --- /dev/null +++ b/apps/web/scripts/check-env.js @@ -0,0 +1,35 @@ +const fs = require('fs'); +const path = require('path'); + +const colors = { + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + reset: '\x1b[0m', +}; + +const rootDir = path.join(__dirname, '..'); +const examplePath = path.join(rootDir, '.env.local.example'); +const localPath = path.join(rootDir, '.env.local'); + +console.log('Checking environment variables...'); + +if (!fs.existsSync(localPath)) { + console.warn(`${colors.yellow}Warning: .env.local file is missing.${colors.reset}`); + + if (fs.existsSync(examplePath)) { + console.log( + `Please copy ${colors.yellow}.env.local.example${colors.reset} to ${colors.yellow}.env.local${colors.reset} and configure it.` + ); + } else { + console.log( + `Please ensure you have a ${colors.yellow}.env.local${colors.reset} file configured.` + ); + } + // Do not block local dev; features will validate their own env vars on-demand. + process.exit(0); +} + +console.log( + `${colors.green}✅ Environment configuration file found.${colors.reset}\n` +); \ No newline at end of file diff --git a/apps/web/src/utils/bugsnag.ts b/apps/web/src/utils/bugsnag.ts index 768ca228af4..1f54941b269 100644 --- a/apps/web/src/utils/bugsnag.ts +++ b/apps/web/src/utils/bugsnag.ts @@ -1,6 +1,7 @@ // import React from 'react'; import type { BugsnagPluginReactResult } from '@bugsnag/plugin-react'; import type { OnErrorCallback } from '@bugsnag/core/types/common'; +import { formatMissingEnvMessage, getEnv } from 'apps/web/src/utils/env'; type BugsnagClientType = { notify: (error: Error | string, onError?: OnErrorCallback) => void; @@ -26,11 +27,31 @@ async function inititializeBugsnag() { BugsnagPluginReactInstance = (await import('@bugsnag/plugin-react')).default; try { + const apiKey = getEnv('NEXT_PUBLIC_BUGSNAG_API_KEY'); + const notifyUrl = getEnv('NEXT_PUBLIC_BUGSNAG_NOTIFY_URL'); + const sessionsUrl = getEnv('NEXT_PUBLIC_BUGSNAG_SESSIONS_URL'); + + // Bugsnag is optional; only initialize when it is configured. + if (!apiKey || !notifyUrl || !sessionsUrl) { + if (process.env.NODE_ENV !== 'production') { + const missing = [ + !apiKey ? 'NEXT_PUBLIC_BUGSNAG_API_KEY' : null, + !notifyUrl ? 'NEXT_PUBLIC_BUGSNAG_NOTIFY_URL' : null, + !sessionsUrl ? 'NEXT_PUBLIC_BUGSNAG_SESSIONS_URL' : null, + ].filter(Boolean); + console.warn( + formatMissingEnvMessage(missing.join(', '), { feature: 'Bugsnag' }), + ); + } + BugsnagClient = null; + return; + } + BugsnagClient.start({ - apiKey: process.env.NEXT_PUBLIC_BUGSNAG_API_KEY as string, + apiKey, endpoints: { - notify: process.env.NEXT_PUBLIC_BUGSNAG_NOTIFY_URL as string, - sessions: process.env.NEXT_PUBLIC_BUGSNAG_SESSIONS_URL as string, + notify: notifyUrl, + sessions: sessionsUrl, }, plugins: [new BugsnagPluginReactInstance()], }); diff --git a/apps/web/src/utils/datastores/postgres/index.ts b/apps/web/src/utils/datastores/postgres/index.ts index 02e69bd133c..37bdd3f0c0b 100644 --- a/apps/web/src/utils/datastores/postgres/index.ts +++ b/apps/web/src/utils/datastores/postgres/index.ts @@ -3,12 +3,23 @@ import { Pool } from 'pg'; import { isDevelopment } from 'apps/web/src/constants'; import { Database } from './types'; import { logger } from 'apps/web/src/utils/logger'; +import { requireEnv } from 'apps/web/src/utils/env'; function createDefaultPostgresManager() { - const user = isDevelopment ? process.env.POSTGRES_USER_DEVELOPMENT : process.env.POSTGRES_USER; - const password = isDevelopment ? process.env.POSTGRES_PASSWORD_DEVELOPMENT : process.env.POSTGRES_PASSWORD; - const host = isDevelopment ? process.env.POSTGRES_HOST_DEVELOPMENT : process.env.POSTGRES_HOST; - const dbName = isDevelopment ? process.env.POSTGRES_DB_NAME_DEVELOPMENT : process.env.POSTGRES_DB_NAME; + // Only validate when DB is actually used (getDb()). + const feature = 'Postgres'; + const user = isDevelopment + ? requireEnv('POSTGRES_USER_DEVELOPMENT', { feature }) + : requireEnv('POSTGRES_USER', { feature }); + const password = isDevelopment + ? requireEnv('POSTGRES_PASSWORD_DEVELOPMENT', { feature }) + : requireEnv('POSTGRES_PASSWORD', { feature }); + const host = isDevelopment + ? requireEnv('POSTGRES_HOST_DEVELOPMENT', { feature }) + : requireEnv('POSTGRES_HOST', { feature }); + const dbName = isDevelopment + ? requireEnv('POSTGRES_DB_NAME_DEVELOPMENT', { feature }) + : requireEnv('POSTGRES_DB_NAME', { feature }); const connectionString = `postgresql://${user}:${password}@${host}:5432/${dbName}`; const poolConfig = isDevelopment ? { diff --git a/apps/web/src/utils/env.ts b/apps/web/src/utils/env.ts new file mode 100644 index 00000000000..ffde48c2a5d --- /dev/null +++ b/apps/web/src/utils/env.ts @@ -0,0 +1,41 @@ +type EnvHelp = { + /** + * Human-friendly feature name, e.g. "Cloudinary upload" or "Bugsnag". + */ + feature?: string; + /** + * Where to configure the variable (defaults to apps/web/.env.local). + */ + configFile?: string; + /** + * Example file (defaults to apps/web/.env.local.example). + */ + exampleFile?: string; +}; + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +export function getEnv(name: string): string | undefined { + const value = process.env[name]; + return isNonEmptyString(value) ? value : undefined; +} + +export function formatMissingEnvMessage(name: string, help: EnvHelp = {}): string { + const featurePrefix = help.feature ? `[${help.feature}] ` : ''; + const configFile = help.configFile ?? 'apps/web/.env.local'; + const exampleFile = help.exampleFile ?? 'apps/web/.env.local.example'; + + return ( + `${featurePrefix}Missing required environment variable: ${name}\n` + + `Add it to ${configFile} (you can copy from ${exampleFile}).` + ); +} + +export function requireEnv(name: string, help: EnvHelp = {}): string { + const value = getEnv(name); + if (value) return value; + throw new Error(formatMissingEnvMessage(name, help)); +} + diff --git a/apps/web/src/utils/pinata.ts b/apps/web/src/utils/pinata.ts index 403616bc630..9beb2dae7eb 100644 --- a/apps/web/src/utils/pinata.ts +++ b/apps/web/src/utils/pinata.ts @@ -1,6 +1,15 @@ import { PinataSDK } from 'pinata'; +import { requireEnv } from 'apps/web/src/utils/env'; -export const pinata = new PinataSDK({ - pinataJwt: `${process.env.PINATA_API_KEY}`, - pinataGateway: `${process.env.PINATA_GATEWAY_URL}`, -}); +let _pinata: PinataSDK | undefined; + +export function getPinata(): PinataSDK { + if (_pinata) return _pinata; + + // Only validate when the feature is actually used (e.g. IPFS upload route). + const pinataJwt = requireEnv('PINATA_API_KEY', { feature: 'Pinata' }); + const pinataGateway = requireEnv('PINATA_GATEWAY_URL', { feature: 'Pinata' }); + + _pinata = new PinataSDK({ pinataJwt, pinataGateway }); + return _pinata; +}