diff --git a/.github/actions/download-build-artifact/__tests__/test_main.py b/.github/actions/download-build-artifact/__tests__/test_main.py index e1728fd73..a846b539e 100644 --- a/.github/actions/download-build-artifact/__tests__/test_main.py +++ b/.github/actions/download-build-artifact/__tests__/test_main.py @@ -38,6 +38,13 @@ def make_zip_bytes() -> bytes: return mem.getvalue() +def make_zip_bytes_with_top_level_dir() -> bytes: + mem = io.BytesIO() + with zipfile.ZipFile(mem, "w") as z: + z.writestr("vercel-build-preview/.vercel/output/config.json", "{}") + return mem.getvalue() + + def test_extracts_vercel_output(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: module = load_action_module() @@ -64,3 +71,31 @@ def fake_get(url: str, **kwargs: Any) -> MockResponse: assert failures == [] assert (tmp_path / ".vercel" / "output" / "config.json").exists() monkeypatch.chdir(cwd) + + +def test_extracts_vercel_output_when_nested_in_top_level_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + module = load_action_module() + + inputs = {"token": "ghs_test", "artifact-download-url": "https://api.github.com/art.zip"} + monkeypatch.setattr(module.core, "get_input", lambda name, required=False: inputs.get(name, "")) + + monkeypatch.setenv("GITHUB_API_URL", "https://api.github.com") + + zip_bytes = make_zip_bytes_with_top_level_dir() + + def fake_get(url: str, **kwargs: Any) -> MockResponse: + return MockResponse(ok=True, status_code=200, content=zip_bytes) + + monkeypatch.setattr(module.requests, "get", fake_get) + + cwd = Path.cwd() + monkeypatch.chdir(tmp_path) + + failures: list[str] = [] + monkeypatch.setattr(module.core, "set_failed", lambda m: failures.append(m)) + + module.run() + + assert failures == [] + assert (tmp_path / ".vercel" / "output" / "config.json").exists() + monkeypatch.chdir(cwd) diff --git a/.github/actions/download-build-artifact/src/main.py b/.github/actions/download-build-artifact/src/main.py index 47c7c514c..49ea23a64 100644 --- a/.github/actions/download-build-artifact/src/main.py +++ b/.github/actions/download-build-artifact/src/main.py @@ -42,6 +42,27 @@ def is_allowed_fetch_url(url: str, allowed_hosts: set[str]) -> bool: return parsed.scheme == "https" and parsed.hostname in allowed_hosts +def find_vercel_output_dir(extract_dir: Path) -> Path | None: + direct_candidates = [extract_dir / ".vercel" / "output", extract_dir / "output"] + for candidate in direct_candidates: + if candidate.is_dir(): + return candidate + + # Common case: artifact contains a top-level folder (e.g. "vercel-build-preview/") + # and the output is nested under it. + nested_candidates = list(extract_dir.rglob(".vercel/output")) + for candidate in nested_candidates: + if candidate.is_dir(): + return candidate + + # Fallback: look for directories named "output" that contain Vercel output. + for candidate in extract_dir.rglob("output"): + if candidate.is_dir() and (candidate / "config.json").is_file(): + return candidate + + return None + + def run() -> None: try: token = get_input_compat("token", required=True) @@ -73,10 +94,15 @@ def run() -> None: with zipfile.ZipFile(io.BytesIO(response.content)) as zip_ref: zip_ref.extractall(extract_dir) - candidates = [extract_dir / ".vercel" / "output", extract_dir / "output"] - source = next((p for p in candidates if p.is_dir()), None) + source = find_vercel_output_dir(extract_dir) if not source: - raise RuntimeError("Downloaded artifact did not contain expected output directory.") + top_level = sorted( + [p.name + ("/" if p.is_dir() else "") for p in extract_dir.iterdir()] + ) + raise RuntimeError( + "Downloaded artifact did not contain expected output directory. " + f"Top-level entries: {top_level}" + ) target = Path(".vercel") / "output" if target.exists(): diff --git a/.github/actions/keep-alive/__tests__/test_main.py b/.github/actions/keep-alive/__tests__/test_main.py index 4c23abc2d..100f58d6a 100644 --- a/.github/actions/keep-alive/__tests__/test_main.py +++ b/.github/actions/keep-alive/__tests__/test_main.py @@ -60,9 +60,9 @@ def test_executes_select_1_and_closes_client(monkeypatch: pytest.MonkeyPatch) -> module = load_action_module() def fake_get_input(name: str, required: bool = False) -> str: - if name == "astro-db-remote-url": + if name == "url": return "libsql://example.turso.io" - if name == "astro-db-app-token": + if name == "token": return "token" return "" @@ -103,9 +103,9 @@ def test_prefers_inputs_over_env_vars(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("ASTRO_DB_APP_TOKEN", "env_token") def fake_get_input(name: str, required: bool = False) -> str: - if name == "astro-db-remote-url": + if name == "url": return "libsql://input.turso.io" - if name == "astro-db-app-token": + if name == "token": return "input_token" return "" @@ -136,9 +136,9 @@ def test_normalizes_libsql_url_to_include_trailing_slash(monkeypatch: pytest.Mon module = load_action_module() def fake_get_input(name: str, required: bool = False) -> str: - if name == "astro-db-remote-url": + if name == "url": return "libsql://example.turso.io" - if name == "astro-db-app-token": + if name == "token": return "token" return "" diff --git a/.github/actions/keep-alive/action.yml b/.github/actions/keep-alive/action.yml index 68d80bca2..31e750d8f 100644 --- a/.github/actions/keep-alive/action.yml +++ b/.github/actions/keep-alive/action.yml @@ -2,10 +2,10 @@ name: Keep Alive description: Executes a keep-alive query (SELECT 1) against the Turso DB. inputs: - astro-db-remote-url: + url: description: Turso DB URL. required: true - astro-db-app-token: + token: description: Turso auth token. required: true @@ -17,5 +17,5 @@ runs: run: python3 src/main.py shell: bash env: - INPUT_ASTRO_DB_REMOTE_URL: ${{ inputs.astro-db-remote-url }} - INPUT_ASTRO_DB_APP_TOKEN: ${{ inputs.astro-db-app-token }} + INPUT_URL: ${{ inputs.url }} + INPUT_ASTRO_DB_APP_TOKEN: ${{ inputs.token }} diff --git a/.github/actions/keep-alive/src/main.py b/.github/actions/keep-alive/src/main.py index bba6cb9a9..83c6f53c1 100644 --- a/.github/actions/keep-alive/src/main.py +++ b/.github/actions/keep-alive/src/main.py @@ -27,11 +27,11 @@ def normalize_libsql_url(url: str) -> str: def run() -> None: try: - url = core.get_input("astro-db-remote-url", required=True) - auth_token = core.get_input("astro-db-app-token", required=True) + url = core.get_input("url", required=True) + auth_token = core.get_input("token", required=True) - required_url = normalize_libsql_url(get_required_value(url, "astro-db-remote-url")) - required_auth_token = get_required_value(auth_token, "astro-db-app-token") + required_url = normalize_libsql_url(get_required_value(url, "url")) + required_auth_token = get_required_value(auth_token, "token") client = create_client_sync(url=required_url, auth_token=required_auth_token) try: diff --git a/.github/workflows/build-preview.yml b/.github/workflows/build-preview.yml index de48e5ebc..507a2bf03 100644 --- a/.github/workflows/build-preview.yml +++ b/.github/workflows/build-preview.yml @@ -53,5 +53,5 @@ jobs: uses: actions/upload-artifact@v6 with: name: vercel-build-preview - path: .vercel/output + path: .vercel retention-days: 30 diff --git a/.github/workflows/build-production.yml b/.github/workflows/build-production.yml index 16cb7d171..f76f23859 100644 --- a/.github/workflows/build-production.yml +++ b/.github/workflows/build-production.yml @@ -50,5 +50,5 @@ jobs: uses: actions/upload-artifact@v6 with: name: vercel-build-production - path: .vercel/output + path: .vercel retention-days: 30 diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index d4e54da3c..346882ca7 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -34,8 +34,8 @@ jobs: - name: Execute keep-alive query uses: './.github/actions/keep-alive' with: - astro-db-remote-url: ${{ vars.ASTRO_DB_REMOTE_URL }} - astro-db-app-token: ${{ secrets.ASTRO_DB_APP_TOKEN }} + url: ${{ vars.ASTRO_DB_REMOTE_URL }} + token: ${{ secrets.ASTRO_DB_APP_TOKEN }} ping-preview: name: Ping Turso Preview DB @@ -58,5 +58,5 @@ jobs: - name: Execute keep-alive query uses: './.github/actions/keep-alive' with: - astro-db-remote-url: ${{ vars.ASTRO_DB_REMOTE_URL }} - astro-db-app-token: ${{ secrets.ASTRO_DB_APP_TOKEN }} + url: ${{ vars.ASTRO_DB_REMOTE_URL }} + token: ${{ secrets.ASTRO_DB_APP_TOKEN }} diff --git a/_TODO.md b/_TODO.md index 5f58cca6f..992e8597a 100644 --- a/_TODO.md +++ b/_TODO.md @@ -1,9 +1,7 @@ # TODO -## Refactor API Endpoints to Astro Actions - -### Action / Domain / Responder Pattern +## Astro Actions - Action / Domain / Responder Pattern - The action takes HTTP requests (URLs and their methods) and uses that input to interact with the domain, after which it passes the domain's output to one and only one responder. @@ -27,48 +25,6 @@ - The responder builds the entire HTTP response from the domain's output which is given to it by the action. The Responder is responsible solely for formatting the final response (e.g., JSON, HTML) to be sent back to the client. -### Endpoints: - -- cron/cleanup-confirmations → GET -- cron/cleanup-dsar-requests → GET -- cron/run-all → GET -- social-card/ → GET - -- contact/ → POST (contact form submission) and OPTIONS (CORS pre-flight) -- downloads/submit → POST -- gdpr/consent → POST, GET, DELETE -- gdpr/request-data → POST -- gdpr/export → GET -- gdpr/verify → GET -- health/ → GET -- newsletter/ → POST, OPTIONS -- newsletter/confirm → GET - -### Files importing from `astro:db` - -- _utils/rateLimit.ts -- _utils/rateLimitStore.ts -- cron/cleanup-confirmations.ts -- cron/cleanup-dsar-requests.ts -- gdpr/_utils/consentStore.ts -- gdpr/_utils/dsarStore.ts -- newsletter/_token.ts - -### Cross-endpoint dependencies: - -gdpr: Mostly self-contained, but `verify.ts` does import `deleteNewsletterConfirmationsByEmail` from `@pages/api/newsletter/_token` (line 15). That's a direct dependency on the newsletter code. - -newsletter: `confirm.ts` pulls `markConsentRecordsVerified` from `@pages/api/gdpr/_utils/consentStore` (line 10) to mark double opt-in consent. That's the reciprocal dependency. - -Newsletter hits the gdpr consent endpoint using `recordConsent` in `src/pages/api/_logger/index.ts`. - -If we want to make it feel less inconsistent, we could either (a) rename `_logger` to something like `_consentClient` so its purpose is clearer, or (b) move to a microservices architecture and expose a protected `/api/gdpr/verify` endpoint and have newsletter call it over HTTP as well - but that would need additional auth to prevent abuse. - -**Affected components:** - -- CallToAction/Newsletter -- ContactForm - ## Refactor Theme Colors ### Color vars @@ -111,39 +67,6 @@ cat.text-alternatives: Rules for ensuring that text alternatives are provided fo Implement mitigations in test/e2e/specs/07-performance/PERFORMANCE.md -## Prefetch Links - -The default prefetch strategy when adding the data-astro-prefetch attribute is hover. To change it, you can configure prefetch.defaultStrategy in your astro.config.mjs file. - -hover (default): Prefetch when you hover over or focus on the link. -tap: Prefetch just before you click on the link. -viewport: Prefetch as the links enter the viewport. -load: Prefetch all links on the page after the page is loaded. - -```html - -About -``` - -If you want to prefetch all links, including those without the data-astro-prefetch attribute, you can set prefetch.prefetchAll to true: - -```typescript -// astro.config.mjs -import { defineConfig } from 'astro/config' - -export default defineConfig({ - prefetch: { - prefetchAll: true - } -}) -``` - -You can then opt-out of prefetching for individual links by setting data-astro-prefetch="false": - -```html -About -``` - ## Email Templates Right now we're using string literals to define HTML email templates for site mails. We should use Nunjucks with the rule-checking for valid CSS in HTML emails like we have in the corporate email footer repo. @@ -175,12 +98,6 @@ Needs to add real API key and test See the example image in Social Shares. The social shares UI on mobile should be a modal that slides in from the bottom. -## Themepicker tooltips, extra themes - -- Add additional themes (high contrast) -- Add Carousel -- Add tooltip that makes use of the description field for the theme, explaining what the intent of the theme is - ## Sentry feedback, chat bot tying into my phone and email See note in src/components/scripts/sentry/client.ts - "User Feedback - allow users to report issues" @@ -201,6 +118,8 @@ Add Upstash Search as a Vercel Marketplace Integration. Google Calendar, Apple Calendar, Microsoft Outlook and Teams, and generate iCal/ics files (for all other calendars and cases). +## Troubleshooting deploy workflow issues + `https://github.com/add2cal/add-to-calendar-button` `https://add-to-calendar-button.com/` diff --git a/eslint.config.ts b/eslint.config.ts index c452f33bd..5e777bd69 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -380,6 +380,7 @@ export default [ { files: [ '.github/actions/**/*', + 'src/actions/newsletter.ts', 'src/lib/config/pwa.ts', 'src/lib/config/serviceWorker.ts', 'src/components/scripts/store/__tests__/socialEmbeds.spec.ts', @@ -422,7 +423,7 @@ export default [ 'vitest.setup.ts', 'src/components/scripts/utils/environmentClient.ts', 'src/lib/config/**/*', - 'src/pages/api/_environment/**/*', + 'src/pages/api/_utils/environment/**/*', 'test/e2e/config/runtime/database.ts', 'test/e2e/config/global-setup.ts', 'test/e2e/config/runtime/mockState.ts', @@ -468,8 +469,7 @@ export default [ 'src/components/scripts/utils/siteUrlClient.ts', 'src/lib/config/environmentServer.ts', 'src/lib/config/siteUrlServer.ts', - 'src/pages/api/_environment/index.ts', - 'src/pages/api/_environment/environmentApi.ts', + 'src/pages/api/_utils/environment/environmentApi.ts', 'test/e2e/config/global-setup.ts', ], rules: { @@ -753,7 +753,7 @@ export default [ 'src/**/server/**/*', 'src/lib/**/*.ts', 'src/pages/**/*.astro', - 'src/pages/api/_environment/environmentApi.ts', + 'src/pages/api/_utils/environment/environmentApi.ts', ], rules: { 'no-restricted-imports': [ diff --git a/src/pages/api/_contracts/gdpr.contracts.ts b/src/actions/_contracts/gdpr.contracts.ts similarity index 58% rename from src/pages/api/_contracts/gdpr.contracts.ts rename to src/actions/_contracts/gdpr.contracts.ts index 32187945d..1524281f2 100644 --- a/src/pages/api/_contracts/gdpr.contracts.ts +++ b/src/actions/_contracts/gdpr.contracts.ts @@ -1,19 +1,7 @@ /** - * GDPR API Endpoint Types + * GDPR Actions Types * - * This file contains TypeScript type definitions for the GDPR-related API endpoints. - * These types serve as a contract between the API endpoints and their consumers, - * providing type safety without requiring Swagger/OpenAPI schema generation. - * - * Each interface represents either: - * - Request payloads sent to the API - * - Response payloads returned by the API - * - Database record structures - * - * Used by: - * - /api/gdpr/consent (ConsentRequest, ConsentResponse, ErrorResponse) - * - /api/gdpr/request-data (DSARRequestInput, DSARResponse, ErrorResponse) - * - /api/gdpr/verify (ErrorResponse) + * Type definitions for GDPR-related Astro Actions. */ export interface ConsentRecord { @@ -49,14 +37,12 @@ export interface ConsentResponse { export interface ErrorResponse { success: false error: { - code: - | 'INVALID_UUID' - | 'RATE_LIMIT_EXCEEDED' - | 'NOT_FOUND' - | 'UNAUTHORIZED' - | 'INVALID_REQUEST' - | 'INTERNAL_ERROR' + code: string message: string + requestId?: string + correlationId?: string + retryable?: boolean + details?: Record } } @@ -66,7 +52,7 @@ export interface DSARRequest { email: string requestType: 'ACCESS' | 'DELETE' expiresAt: string - fulfilledAt?: string | undefined + fulfilledAt?: string createdAt: string } @@ -78,4 +64,4 @@ export interface DSARRequestInput { export interface DSARResponse { success: true message: string -} \ No newline at end of file +} diff --git a/src/actions/_environment/environmentActions.ts b/src/actions/_environment/environmentActions.ts new file mode 100644 index 000000000..e90137e0d --- /dev/null +++ b/src/actions/_environment/environmentActions.ts @@ -0,0 +1,68 @@ +import { ActionsFunctionError } from '@actions/_errors/ActionsFunctionError' +import { getOptionalEnv, isUnitTest } from '@lib/config/environmentServer' +export { + isCI, + isE2eTest, + isGitHub, + isTest, + isUnitTest, + isVercel, +} from '@lib/config/environmentServer' + +export const isDev = () => { + return import.meta.env.MODE === 'development' +} + +export const isProd = () => { + return import.meta.env.MODE === 'production' && !isUnitTest() +} + +export function getPrivacyPolicyVersion(): string { + const version = import.meta.env['PRIVACY_POLICY_VERSION'] + if (!version) { + throw new ActionsFunctionError( + 'PRIVACY_POLICY_VERSION environment variable is not set. This should be injected by the PrivacyPolicyVersion integration.' + ) + } + return version +} + +export function getPackageRelease(): string { + const release = import.meta.env['PACKAGE_RELEASE_VERSION'] + if (!release) { + throw new ActionsFunctionError( + 'PACKAGE_RELEASE_VERSION environment variable is not set. This should be injected by the PackageRelease integration.' + ) + } + return release +} + +export function getConvertkitApiKey(): string { + const secret = getOptionalEnv('CONVERTKIT_API_KEY') + if (!secret) { + throw new ActionsFunctionError( + 'CONVERTKIT_API_KEY environment variable is not set. This is either set in a .env file locally during development, in GitHub Secrets and made available in CI runs by the .github/workflows actions, or by Vercel as an env var made available to serverless functions in deployment.' + ) + } + return secret +} + +export function getResendApiKey(): string { + const key = getOptionalEnv('RESEND_API_KEY') + if (!key) { + throw new ActionsFunctionError( + 'RESEND_API_KEY environment variable is not set. This is either set in a .env file locally during development, in GitHub Secrets and made available in CI runs by the .github/workflows actions, or by Vercel as an env var made available to serverless functions in deployment.' + ) + } + return key +} + +export function getSentryDsn(): string { + const key = getOptionalEnv('PUBLIC_SENTRY_DSN') + if (!key) { + throw new ActionsFunctionError( + 'PUBLIC_SENTRY_DSN environment variable is not set. This is either set in a .env file locally during development, in GitHub Secrets and made available in CI runs by the .github/workflows actions, or by Vercel as an env var made available to serverless functions in deployment.' + ) + } + return key +} diff --git a/src/actions/_environment/siteUrlActions.ts b/src/actions/_environment/siteUrlActions.ts new file mode 100644 index 000000000..dbaea9613 --- /dev/null +++ b/src/actions/_environment/siteUrlActions.ts @@ -0,0 +1,15 @@ +import packageJson from '../../../package.json' with { type: 'json' } +import { isVercel } from './environmentActions' +import { getOptionalEnv } from '@lib/config/environmentServer' + +const devServerPort = getOptionalEnv('DEV_SERVER_PORT')?.trim() +const resolvedDevServerPort = devServerPort && devServerPort.length > 0 ? devServerPort : '4321' +const { domain } = packageJson + +export const getSiteUrl = (): string => { + if (isVercel() && domain) { + return `https://${domain}` + } + + return `http://localhost:${resolvedDevServerPort}` +} diff --git a/src/actions/_errors/ActionsFunctionError.ts b/src/actions/_errors/ActionsFunctionError.ts new file mode 100644 index 000000000..f47e7879d --- /dev/null +++ b/src/actions/_errors/ActionsFunctionError.ts @@ -0,0 +1,160 @@ +const DEFAULT_ERROR_MESSAGE = 'Internal server error' +const MIN_ERROR_STATUS = 400 +const MAX_ERROR_STATUS = 599 +const DEFAULT_ERROR_STATUS = 500 +const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]) + +export interface ActionsFunctionErrorParams { + message: string + stack?: string | undefined + cause?: unknown + status?: number | undefined + code?: string | undefined + route?: string | undefined + operation?: string | undefined + requestId?: string | undefined + correlationId?: string | undefined + details?: Record | undefined + retryable?: boolean | undefined +} + +const cloneDetails = (details?: Record): Record | undefined => + details ? { ...details } : undefined + +const normalizeStatus = (status?: number): number => { + if (typeof status !== 'number' || Number.isNaN(status)) { + return DEFAULT_ERROR_STATUS + } + + const truncated = Math.trunc(status) + if (truncated < MIN_ERROR_STATUS) { + return MIN_ERROR_STATUS + } + if (truncated > MAX_ERROR_STATUS) { + return DEFAULT_ERROR_STATUS + } + + return truncated +} + +const isRetryableStatus = (status: number): boolean => RETRYABLE_STATUS_CODES.has(status) + +function normalizeActionsFunctionError(message: unknown): ActionsFunctionErrorParams { + if (message instanceof ActionsFunctionError) { + return message.toParams() + } + + if (message instanceof Error) { + return { + message: message.message || DEFAULT_ERROR_MESSAGE, + stack: message.stack, + cause: message.cause, + } + } + + if (typeof message === 'string') { + const normalized = message.trim() + return { message: normalized || DEFAULT_ERROR_MESSAGE } + } + + if (message && typeof message === 'object') { + const params = message as Partial + return { + message: + typeof params.message === 'string' && params.message.trim() + ? params.message.trim() + : DEFAULT_ERROR_MESSAGE, + stack: params.stack, + cause: params.cause, + status: params.status, + code: params.code, + route: params.route, + operation: params.operation, + requestId: params.requestId, + correlationId: params.correlationId, + details: cloneDetails(params.details), + retryable: params.retryable, + } + } + + if (message === undefined || message === null) { + return { message: DEFAULT_ERROR_MESSAGE } + } + + return { message: String(message) } +} + +export class ActionsFunctionError extends Error { + status: number + isClientError: boolean + isServerError: boolean + retryable: boolean + code?: string | undefined + route?: string | undefined + operation?: string | undefined + requestId?: string | undefined + correlationId?: string | undefined + details?: Record | undefined + + constructor(message?: unknown, context?: Partial) { + const normalized = normalizeActionsFunctionError(message) + const merged: ActionsFunctionErrorParams = { + ...normalized, + ...(context || {}), + } + + super(merged.message) + + Object.defineProperty(this, 'name', { + value: 'ActionsFunctionError', + enumerable: false, + configurable: true, + }) + + Object.setPrototypeOf(this, new.target.prototype) + + if ('captureStackTrace' in Error) Error.captureStackTrace(this, ActionsFunctionError) + if ('stackTraceLimit' in Error) Error.stackTraceLimit = Infinity + + this.message = merged.message + this.cause = merged.cause + this.status = normalizeStatus(merged.status) + this.isClientError = this.status >= MIN_ERROR_STATUS && this.status < DEFAULT_ERROR_STATUS + this.isServerError = this.status >= DEFAULT_ERROR_STATUS + this.retryable = + typeof merged.retryable === 'boolean' ? merged.retryable : isRetryableStatus(this.status) + this.code = merged.code + this.route = merged.route + this.operation = merged.operation + this.requestId = merged.requestId + this.correlationId = merged.correlationId + this.details = cloneDetails(merged.details) + } + + static from(error: unknown, overrides?: Partial): ActionsFunctionError { + if (error instanceof ActionsFunctionError) { + return new ActionsFunctionError(error.toParams(), overrides) + } + return new ActionsFunctionError(error, overrides) + } + + toParams(): ActionsFunctionErrorParams { + return { + message: this.message, + stack: this.stack, + cause: this.cause, + status: this.status, + code: this.code, + route: this.route, + operation: this.operation, + requestId: this.requestId, + correlationId: this.correlationId, + details: cloneDetails(this.details), + retryable: this.retryable, + } + } + + getSafeMessage(fallbackMessage = DEFAULT_ERROR_MESSAGE): string { + return this.isClientError ? this.message : fallbackMessage + } +} diff --git a/src/actions/_sentry/index.ts b/src/actions/_sentry/index.ts new file mode 100644 index 000000000..37d0fd710 --- /dev/null +++ b/src/actions/_sentry/index.ts @@ -0,0 +1,34 @@ +import { init as sentryInit } from '@sentry/astro' +import { getPackageRelease, getSentryDsn, isDev, isProd } from '@actions/_environment/environmentActions' + +let initialized = false + +export function ensureActionsSentry(): void { + if (initialized) { + return + } + + if (!isProd()) { + return + } + + sentryInit({ + dsn: getSentryDsn(), + release: getPackageRelease(), + environment: 'production', + tracesSampleRate: 1.0, + sendDefaultPii: false, + attachStacktrace: true, + maxBreadcrumbs: 100, + beforeSend(event) { + if (isDev()) { + return null + } + return event + }, + }) + + initialized = true +} + +ensureActionsSentry() diff --git a/src/pages/api/_utils/rateLimit.ts b/src/actions/_utils/rateLimit.ts similarity index 82% rename from src/pages/api/_utils/rateLimit.ts rename to src/actions/_utils/rateLimit.ts index 1f325fca0..5424aa67f 100644 --- a/src/pages/api/_utils/rateLimit.ts +++ b/src/actions/_utils/rateLimit.ts @@ -1,6 +1,6 @@ import { isDbError } from 'astro:db' -import { isDev, isTest } from '@pages/api/_environment/environmentApi' -import { withRateLimitWindow } from '@pages/api/_utils/rateLimitStore' +import { isDev, isTest } from '@actions/_environment/environmentActions' +import { withRateLimitWindow } from '@actions/_utils/rateLimitStore' export type RateLimiter = { limit: (_identifier: string) => Promise<{ success: boolean; reset: number | undefined }> @@ -10,7 +10,6 @@ export type RateLimiterKey = 'consent' | 'consentRead' | 'export' | 'delete' | ' export type RateLimiterMap = Record -// Simple in-memory rate limiting (use Redis in production) const rateLimitStore = new Map() type RateLimiterConfig = { @@ -46,24 +45,18 @@ export async function checkRateLimit( } } -/** - * Check if the client fingerprint (hashed IP/UA) exceeded the contact form limit. - * Disabled in development and CI environments. Callers should hash PII before - * invoking this helper to keep rate-limiting compliant with GDPR requirements. - */ export function checkContactRateLimit(ipFingerprint: string): boolean { - // Skip rate limiting in dev/test environments if (isDev() || isTest()) { return true } const now = Date.now() - const windowMs = 15 * 60 * 1000 // 15 minutes - const maxRequests = 5 // Lower limit for contact form + const windowMs = 15 * 60 * 1000 + const maxRequests = 5 const key = `contact_rate_limit_${ipFingerprint}` const requests = rateLimitStore.get(key) || [] - const validRequests = requests.filter((timestamp) => now - timestamp < windowMs) + const validRequests = requests.filter(timestamp => now - timestamp < windowMs) if (validRequests.length >= maxRequests) { return false @@ -73,6 +66,7 @@ export function checkContactRateLimit(ipFingerprint: string): boolean { rateLimitStore.set(key, validRequests) return true } + function createLimiter(config: RateLimiterConfig): RateLimiter { return { limit: identifier => applyRateLimit(config, identifier), diff --git a/src/pages/api/_utils/rateLimitStore.ts b/src/actions/_utils/rateLimitStore.ts similarity index 100% rename from src/pages/api/_utils/rateLimitStore.ts rename to src/actions/_utils/rateLimitStore.ts diff --git a/src/actions/_utils/requestContext.ts b/src/actions/_utils/requestContext.ts new file mode 100644 index 000000000..ee9c68f94 --- /dev/null +++ b/src/actions/_utils/requestContext.ts @@ -0,0 +1,99 @@ +import { createHash } from 'node:crypto' +import type { AstroCookies } from 'astro' + +const FUNCTIONAL_CONSENT_COOKIE = 'consent_functional' + +export function createRateLimitIdentifier(scope: string, fingerprint?: string): string { + return `${scope}:${fingerprint ?? 'anonymous'}` +} + +export function buildRequestFingerprint(options: { + route: string + request: Request + cookies?: AstroCookies + clientAddress?: string +}): { fingerprint?: string; consentFunctional: boolean } { + const hashSalt = options.route + const consentFunctional = readFunctionalConsent(options.cookies) + const requestMeta = buildRequestMetadata(options.request, hashSalt, consentFunctional, options.clientAddress) + const fingerprint = requestMeta.ipHash ?? requestMeta.userAgentHash + + const result: { fingerprint?: string; consentFunctional: boolean } = { + consentFunctional, + } + + if (fingerprint) { + result.fingerprint = fingerprint + } + + return result +} + +type RequestMetadata = { + method?: string + ip?: string + ipHash?: string + userAgent?: string + userAgentHash?: string +} + +function readFunctionalConsent(cookies?: AstroCookies): boolean { + const consentValue = cookies?.get(FUNCTIONAL_CONSENT_COOKIE)?.value + return consentValue === 'true' +} + +function buildRequestMetadata( + request: Request, + salt: string, + includeRawPII: boolean, + clientAddress?: string, +): RequestMetadata { + const method = request.method + const ip = extractClientIp(request) ?? clientAddress + const userAgent = request.headers.get('user-agent') ?? undefined + + const metadata: RequestMetadata = { + ...(method && { method }), + ...(ip && { ipHash: hashIdentifier(ip, salt) }), + ...(userAgent && { userAgentHash: hashIdentifier(userAgent, salt) }), + } + + if (includeRawPII) { + if (ip) { + metadata.ip = ip + } + if (userAgent) { + metadata.userAgent = userAgent + } + } + + return metadata +} + +function extractClientIp(request: Request): string | undefined { + const headers = request.headers + const candidates = [ + headers.get('x-forwarded-for'), + headers.get('cf-connecting-ip'), + headers.get('x-real-ip'), + headers.get('fastly-client-ip'), + ] + + for (const value of candidates) { + if (value) { + const [first] = value.split(',') + if (first) { + const normalized = first.trim() + if (normalized) { + return normalized + } + } + } + } + + return undefined +} + +function hashIdentifier(value: string, salt: string): string { + return createHash('sha256').update(`${salt}:${value}`).digest('hex') +} diff --git a/src/actions/contact/responder.ts b/src/actions/contact/responder.ts new file mode 100644 index 000000000..f366ce1b7 --- /dev/null +++ b/src/actions/contact/responder.ts @@ -0,0 +1,299 @@ +import { Buffer } from 'node:buffer' +import { Resend } from 'resend' +import emailValidator from 'email-validator' +import { v4 as uuidv4, validate as uuidValidate } from 'uuid' +import { ActionError, defineAction } from 'astro:actions' +import { checkContactRateLimit } from '@actions/_utils/rateLimit' +import { buildRequestFingerprint, createRateLimitIdentifier } from '@actions/_utils/requestContext' +import { getPrivacyPolicyVersion, getResendApiKey, isDev, isTest } from '@actions/_environment/environmentActions' +import { createConsentRecord } from '@actions/gdpr/domain/consentStore' + +type ContactFormData = { + name: string + email: string + phone?: string + message: string + consent?: boolean + DataSubjectId?: string + service?: string + budget?: string + timeline?: string + website?: string +} + +type FileAttachment = { + filename: string + content: Buffer + contentType: string + size: number +} + +type EmailData = { + from: string + to: string + subject: string + html: string +} + +function escapeHtml(text: string): string { + const map: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + } + return text.replace(/[&<>"']/g, char => map[char] || char) +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB` + return `${(bytes / (1024 * 1024)).toFixed(2)} MB` +} + +function validateInput(body: ContactFormData): string[] { + const errors: string[] = [] + + if (!body.name?.trim()) { + errors.push('Name is required') + } else if (body.name.length < 2) { + errors.push('Name must be at least 2 characters') + } else if (body.name.length > 100) { + errors.push('Name must be less than 100 characters') + } + + if (!body.email?.trim()) { + errors.push('Email is required') + } else if (!emailValidator.validate(body.email.trim())) { + errors.push('Invalid email address') + } + + if (!body.message?.trim()) { + errors.push('Message is required') + } else if (body.message.length < 10) { + errors.push('Message must be at least 10 characters') + } else if (body.message.length > 2000) { + errors.push('Message must be less than 2000 characters') + } + + const spamPatterns = ['viagra', 'cialis', 'casino', 'poker', 'lottery'] + const messageContent = `${body.name} ${body.email} ${body.message}`.toLowerCase() + if (spamPatterns.some(pattern => messageContent.includes(pattern))) { + errors.push('Message appears to contain spam') + } + + return errors +} + +function generateEmailContent(data: ContactFormData, files: FileAttachment[]): string { + const fields = [ + `

Name: ${escapeHtml(data.name)}

`, + `

Email: ${escapeHtml(data.email)}

`, + ] + + if (data.phone) fields.push(`

Phone: ${escapeHtml(data.phone)}

`) + if (data.service) fields.push(`

Service: ${escapeHtml(data.service)}

`) + if (data.budget) fields.push(`

Budget: ${escapeHtml(data.budget)}

`) + if (data.timeline) fields.push(`

Timeline: ${escapeHtml(data.timeline)}

`) + if (data.website) fields.push(`

Website: ${escapeHtml(data.website)}

`) + + fields.push('

Message:

') + fields.push(`

${escapeHtml(data.message).replace(/\n/g, '
')}

`) + + if (files.length > 0) { + fields.push('

Attachments:

') + fields.push('
    ') + files.forEach(file => { + fields.push(`
  • ${escapeHtml(file.filename)} (${formatFileSize(file.size)})
  • `) + }) + fields.push('
') + } + + fields.push(`

Consent Given: ${data.consent ? 'Yes' : 'No'}

`) + + return ` + + + + + + + +

New Contact Form Submission

+${fields.join('\n')} + + +`.trim() +} + +async function sendEmail(emailData: EmailData, files: FileAttachment[]): Promise { + if (isTest() || isDev()) { + return + } + + const resend = new Resend(getResendApiKey()) + const attachments = files.map(file => ({ filename: file.filename, content: file.content })) + + const result = await resend.emails.send({ + from: emailData.from, + to: emailData.to, + subject: emailData.subject, + html: emailData.html, + ...(attachments.length > 0 && { attachments }), + }) + + if (!result.data) { + throw new ActionError({ code: 'BAD_GATEWAY', message: 'Failed to send email. Please try again later.' }) + } +} + +function parseBoolean(value: FormDataEntryValue | null): boolean { + if (value === null) return false + if (typeof value === 'string') return value === 'true' + return false +} + +function readString(form: FormData, key: string): string { + const value = form.get(key) + return typeof value === 'string' ? value : '' +} + +async function parseAttachments(form: FormData): Promise { + const files: FileAttachment[] = [] + const allowedTypes = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ] + const maxFileSize = 10 * 1024 * 1024 + const maxFiles = 5 + + let fileCount = 0 + for (const [key, value] of form.entries()) { + if (key.startsWith('file') && value instanceof File && value.size > 0) { + fileCount++ + + if (fileCount > maxFiles) { + throw new ActionError({ code: 'BAD_REQUEST', message: `Maximum ${maxFiles} files allowed` }) + } + + if (value.size > maxFileSize) { + throw new ActionError({ code: 'BAD_REQUEST', message: `File ${value.name} exceeds 10MB limit` }) + } + + if (!allowedTypes.includes(value.type)) { + throw new ActionError({ code: 'BAD_REQUEST', message: `File type ${value.type} not allowed` }) + } + + const buffer = Buffer.from(await value.arrayBuffer()) + files.push({ + filename: value.name, + content: buffer, + contentType: value.type, + size: value.size, + }) + } + } + + return files +} + +export const contact = { + submit: defineAction({ + accept: 'form', + handler: async (form: FormData, context): Promise<{ success: true; message: string }> => { + const { fingerprint } = buildRequestFingerprint({ + route: '/_actions/contact/submit', + request: context.request, + cookies: context.cookies, + clientAddress: context.clientAddress, + }) + + const rateLimitIdentifier = createRateLimitIdentifier('contact', fingerprint) + if (!checkContactRateLimit(rateLimitIdentifier)) { + throw new ActionError({ + code: 'TOO_MANY_REQUESTS', + message: 'Too many form submissions. Please try again later.', + }) + } + + const formData: ContactFormData = { + name: readString(form, 'name'), + email: readString(form, 'email'), + message: readString(form, 'message'), + consent: parseBoolean(form.get('consent')), + } + + const phone = readString(form, 'phone') + const service = readString(form, 'service') + const budget = readString(form, 'budget') + const timeline = readString(form, 'timeline') + const website = readString(form, 'website') + + if (phone) formData.phone = phone + if (service) formData.service = service + if (budget) formData.budget = budget + if (timeline) formData.timeline = timeline + if (website) formData.website = website + + const files = await parseAttachments(form) + + const validationErrors = validateInput(formData) + if (validationErrors.length > 0) { + throw new ActionError({ code: 'BAD_REQUEST', message: validationErrors[0] ?? 'Invalid form submission' }) + } + + const userAgent = context.request.headers.get('user-agent') || 'unknown' + const ip = + context.clientAddress || + context.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || + context.request.headers.get('x-real-ip') || + 'unknown' + + if (formData.consent) { + let subjectId = formData.DataSubjectId + if (!subjectId) { + subjectId = uuidv4() + } else if (!uuidValidate(subjectId)) { + throw new ActionError({ code: 'BAD_REQUEST', message: 'Invalid DataSubjectId format' }) + } + + await createConsentRecord({ + dataSubjectId: subjectId, + email: formData.email.trim(), + purposes: ['contact'], + source: 'contact_form', + userAgent, + ipAddress: ip !== 'unknown' ? ip : null, + privacyPolicyVersion: getPrivacyPolicyVersion(), + consentText: null, + verified: true, + }) + } + + const htmlContent = generateEmailContent(formData, files) + await sendEmail( + { + from: 'contact@webstackbuilders.com', + to: 'info@webstackbuilders.com', + subject: `Contact Form: ${formData.name}`, + html: htmlContent, + }, + files, + ) + + return { + success: true, + message: 'Thank you for your message. We will get back to you soon!', + } + }, + }), +} diff --git a/src/actions/downloads/responder.ts b/src/actions/downloads/responder.ts new file mode 100644 index 000000000..8c5d1c3b0 --- /dev/null +++ b/src/actions/downloads/responder.ts @@ -0,0 +1,46 @@ +import emailValidator from 'email-validator' +import { defineAction } from 'astro:actions' +import { z } from 'astro:schema' + +type DownloadFormData = { + firstName: string + lastName: string + workEmail: string + jobTitle: string + companyName: string +} + +const inputSchema = z.object({ + firstName: z.string().trim().min(1), + lastName: z.string().trim().min(1), + workEmail: z + .string() + .trim() + .min(1) + .refine(value => emailValidator.validate(value), 'Invalid email address'), + jobTitle: z.string().trim().min(1), + companyName: z.string().trim().min(1), +}) + +export const downloads = { + submit: defineAction({ + accept: 'json', + input: inputSchema, + handler: async (input): Promise<{ success: true; message: string }> => { + const data = input as DownloadFormData + + console.log('Download form submission:', { + name: `${data.firstName} ${data.lastName}`, + email: data.workEmail, + jobTitle: data.jobTitle, + company: data.companyName, + timestamp: new Date().toISOString(), + }) + + return { + success: true, + message: 'Form submitted successfully', + } + }, + }), +} diff --git a/src/pages/api/gdpr/_dsarVerificationEmails.ts b/src/actions/gdpr/_dsarVerificationEmails.ts similarity index 55% rename from src/pages/api/gdpr/_dsarVerificationEmails.ts rename to src/actions/gdpr/_dsarVerificationEmails.ts index d9afafd05..266b6609c 100644 --- a/src/pages/api/gdpr/_dsarVerificationEmails.ts +++ b/src/actions/gdpr/_dsarVerificationEmails.ts @@ -1,28 +1,15 @@ -/** - * DSAR (Data Subject Access Request) email service - * Sends verification emails for data access and deletion requests using Resend - */ import { Resend } from 'resend' import { dsarVerificationEmailHtml } from '@content/email/dsar.html' import { dsarVerificationEmailText } from '@content/email/dsar.text' -import { getResendApiKey, isDev, isTest } from '@pages/api/_environment/environmentApi' -import { getSiteUrl } from '@pages/api/_environment/siteUrlApi' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' +import { getResendApiKey, isDev, isTest } from '@actions/_environment/environmentActions' +import { getSiteUrl } from '@actions/_environment/siteUrlActions' +import { ActionsFunctionError } from '@actions/_errors/ActionsFunctionError' -/** - * Send verification email for DSAR request - * - * @param email - User's email address - * @param token - Verification token - * @param requestType - Type of request (ACCESS or DELETE) - * @returns Promise that resolves when email is sent - */ -export async function sendDSARVerificationEmail( +export async function sendDsarVerificationEmail( email: string, token: string, - requestType: 'ACCESS' | 'DELETE' + requestType: 'ACCESS' | 'DELETE', ): Promise { - // Skip actual email sending in dev/test environments if (isDev() || isTest()) { console.log('[DEV/TEST MODE] DSAR verification email would be sent:', { email, token, requestType }) return @@ -32,24 +19,22 @@ export async function sendDSARVerificationEmail( try { resend = new Resend(getResendApiKey()) } catch (error) { - const message = `[DSAR Email] Failed to initialize Resend client` + const message = '[DSAR Email] Failed to initialize Resend client' console.error(message, error) - throw new ApiFunctionError({ + throw new ActionsFunctionError({ message, cause: error, code: 'DSAR_EMAIL_INIT_FAILED', status: 500, - route: '/api/gdpr', - operation: 'sendDSARVerificationEmail' + route: 'actions:gdpr', + operation: 'sendDsarVerificationEmail', }) } - const verifyUrl = `${getSiteUrl()}/api/gdpr/verify?token=${token}` + const verifyUrl = `${getSiteUrl()}/privacy/my-data?token=${token}` const expiresIn = '24 hours' const actionText = requestType === 'ACCESS' ? 'access your data' : 'delete your data' - const subject = requestType === 'ACCESS' - ? 'Verify Your Data Access Request' - : 'Verify Your Data Deletion Request' + const subject = requestType === 'ACCESS' ? 'Verify Your Data Access Request' : 'Verify Your Data Deletion Request' const html = dsarVerificationEmailHtml({ subject, @@ -80,15 +65,15 @@ export async function sendDSARVerificationEmail( }) if (result.error) { - const message = `[DSAR Email] Failed to send verification` + const message = '[DSAR Email] Failed to send verification' console.error(message, result.error) - throw new ApiFunctionError({ + throw new ActionsFunctionError({ message, cause: result.error, code: 'DSAR_EMAIL_SEND_FAILED', status: 502, - route: '/api/gdpr', - operation: 'sendDSARVerificationEmail' + route: 'actions:gdpr', + operation: 'sendDsarVerificationEmail', }) } @@ -98,15 +83,15 @@ export async function sendDSARVerificationEmail( messageId: result.data?.id, }) } catch (error) { - const message = `[DSAR Email] Error sending verification` + const message = '[DSAR Email] Error sending verification' console.error(message, error) - throw new ApiFunctionError({ + throw new ActionsFunctionError({ message, cause: error, code: 'DSAR_EMAIL_SEND_FAILED', status: 502, - route: '/api/gdpr', - operation: 'sendDSARVerificationEmail' + route: 'actions:gdpr', + operation: 'sendDsarVerificationEmail', }) } -} \ No newline at end of file +} diff --git a/src/pages/api/gdpr/_utils/consentStore.ts b/src/actions/gdpr/domain/consentStore.ts similarity index 96% rename from src/pages/api/gdpr/_utils/consentStore.ts rename to src/actions/gdpr/domain/consentStore.ts index 7c8362ab7..2cf825adf 100644 --- a/src/pages/api/gdpr/_utils/consentStore.ts +++ b/src/actions/gdpr/domain/consentStore.ts @@ -83,10 +83,7 @@ export async function deleteConsentRecordsByEmail(email: string): Promise { +export async function markConsentRecordsVerified(email: string, dataSubjectId: string): Promise { const normalizedEmail = normalizeEmail(email) const updated = await db .update(consentEvents) diff --git a/src/pages/api/gdpr/_utils/dsarStore.ts b/src/actions/gdpr/domain/dsarStore.ts similarity index 76% rename from src/pages/api/gdpr/_utils/dsarStore.ts rename to src/actions/gdpr/domain/dsarStore.ts index 81df67872..4c66f2207 100644 --- a/src/pages/api/gdpr/_utils/dsarStore.ts +++ b/src/actions/gdpr/domain/dsarStore.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' import { and, db, dsarRequests, eq, gt, isNull } from 'astro:db' -import type { DSARRequestInput } from '@pages/api/_contracts/gdpr.contracts' +import type { DSARRequestInput } from '@actions/_contracts/gdpr.contracts' export type DsarRequestRecord = typeof dsarRequests.$inferSelect @@ -33,9 +33,7 @@ export async function findActiveRequestByEmail( return record } -export async function createDsarRequest( - input: CreateDsarRequestInput, -): Promise { +export async function createDsarRequest(input: CreateDsarRequestInput): Promise { const [record] = await db .insert(dsarRequests) .values({ @@ -56,18 +54,10 @@ export async function createDsarRequest( } export async function findDsarRequestByToken(token: string): Promise { - const [record] = await db - .select() - .from(dsarRequests) - .where(eq(dsarRequests.token, token)) - .limit(1) - + const [record] = await db.select().from(dsarRequests).where(eq(dsarRequests.token, token)).limit(1) return record } export async function markDsarRequestFulfilled(token: string): Promise { - await db - .update(dsarRequests) - .set({ fulfilledAt: new Date() }) - .where(eq(dsarRequests.token, token)) + await db.update(dsarRequests).set({ fulfilledAt: new Date() }).where(eq(dsarRequests.token, token)) } diff --git a/src/actions/gdpr/responder.ts b/src/actions/gdpr/responder.ts new file mode 100644 index 000000000..8b1bcd0e7 --- /dev/null +++ b/src/actions/gdpr/responder.ts @@ -0,0 +1,411 @@ +import emailValidator from 'email-validator' +import { validate as uuidValidate } from 'uuid' +import { ActionError, defineAction } from 'astro:actions' +import { z } from 'astro:schema' +import { getPrivacyPolicyVersion } from '@actions/_environment/environmentActions' +import { checkRateLimit, rateLimiters } from '@actions/_utils/rateLimit' +import { buildRequestFingerprint, createRateLimitIdentifier } from '@actions/_utils/requestContext' +import type { ConsentRequest, ConsentResponse, DSARRequest, DSARRequestInput, DSARResponse } from '@actions/_contracts/gdpr.contracts' +import { + createConsentRecord, + deleteConsentRecords, + deleteConsentRecordsByEmail, + findConsentRecords, + findConsentRecordsByEmail, + type ConsentEventRecord, +} from '@actions/gdpr/domain/consentStore' +import { + createDsarRequest, + findActiveRequestByEmail, + findDsarRequestByToken, + markDsarRequestFulfilled, +} from '@actions/gdpr/domain/dsarStore' +import { sendDsarVerificationEmail } from '@actions/gdpr/_dsarVerificationEmails' +import { deleteNewsletterConfirmationsByEmail } from '@actions/newsletter/action' + +const CONSENT_PURPOSES = ['contact', 'marketing', 'analytics', 'downloads'] as const +type ConsentPurpose = (typeof CONSENT_PURPOSES)[number] + +const CONSENT_SOURCES = ['contact_form', 'newsletter_form', 'download_form', 'cookies_modal', 'preferences_page'] as const +type ConsentSource = (typeof CONSENT_SOURCES)[number] + +const DEFAULT_SOURCE: ConsentSource = 'cookies_modal' +const DEFAULT_USER_AGENT = 'unknown' + +const isConsentPurpose = (value: unknown): value is ConsentPurpose => + typeof value === 'string' && CONSENT_PURPOSES.includes(value as ConsentPurpose) + +const isConsentSource = (value: unknown): value is ConsentSource => + typeof value === 'string' && CONSENT_SOURCES.includes(value as ConsentSource) + +const sanitizePurposes = (purposes: unknown): ConsentPurpose[] => (Array.isArray(purposes) ? purposes.filter(isConsentPurpose) : []) +const sanitizeSource = (source: unknown): ConsentSource => (isConsentSource(source) ? source : DEFAULT_SOURCE) + +const normalizeNullableString = (value?: string | null): string | null => { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +const normalizeUserAgent = (value?: string | null): string => normalizeNullableString(value) ?? DEFAULT_USER_AGENT + +const buildRateLimitError = (reset: number | undefined, message?: string) => { + const retryAfterMs = typeof reset === 'number' ? Math.max(0, reset - Date.now()) : 0 + const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000)) + throw new ActionError({ + code: 'TOO_MANY_REQUESTS', + message: message ?? `Try again in ${retryAfterSeconds}s`, + }) +} + +const mapConsentRecord = (record: ConsentEventRecord): ConsentResponse['record'] => { + const normalizedEmail = normalizeNullableString(record.email) + const normalizedIpAddress = normalizeNullableString(record.ipAddress) + const normalizedConsentText = normalizeNullableString(record.consentText) + + const mapped: ConsentResponse['record'] = { + id: record.id, + DataSubjectId: record.dataSubjectId, + purposes: sanitizePurposes(record.purposes), + timestamp: record.createdAt instanceof Date ? record.createdAt.toISOString() : new Date(record.createdAt).toISOString(), + source: sanitizeSource(record.source), + userAgent: normalizeUserAgent(record.userAgent), + privacyPolicyVersion: record.privacyPolicyVersion ?? getPrivacyPolicyVersion(), + verified: record.verified, + } + + if (normalizedEmail) { + mapped.email = normalizedEmail + } + if (normalizedIpAddress) { + mapped.ipAddress = normalizedIpAddress + } + if (normalizedConsentText) { + mapped.consentText = normalizedConsentText + } + + return mapped +} + +const consentCreateSchema = z.custom() +const consentListSchema = z.object({ + DataSubjectId: z.string().min(1), + purpose: z.string().optional(), +}) +const consentDeleteSchema = z.object({ + DataSubjectId: z.string().min(1), +}) + +const dsarRequestSchema = z.object({ + email: z.string().min(1), + requestType: z.enum(['ACCESS', 'DELETE']), +}) + +export type DsarVerifyResult = + | { status: 'invalid' | 'expired' | 'already-completed' | 'error' } + | { status: 'deleted' } + | { status: 'download'; filename: string; json: string } + +export async function verifyDsarToken(token: string): Promise { + const dbRequest = await findDsarRequestByToken(token) + + if (!dbRequest) { + return { status: 'invalid' } + } + + const dsarRequest: DSARRequest = { + id: dbRequest.id, + token: dbRequest.token, + email: dbRequest.email, + requestType: dbRequest.requestType as DSARRequest['requestType'], + expiresAt: dbRequest.expiresAt.toISOString(), + createdAt: dbRequest.createdAt.toISOString(), + ...(dbRequest.fulfilledAt && { fulfilledAt: dbRequest.fulfilledAt.toISOString() }), + } + + if (dsarRequest.fulfilledAt) { + return { status: 'already-completed' } + } + + if (new Date(dsarRequest.expiresAt) < new Date()) { + return { status: 'expired' } + } + + const email = dsarRequest.email + const requestType = dsarRequest.requestType + + if (requestType === 'ACCESS') { + const consentRecords = await findConsentRecordsByEmail(email) + await markDsarRequestFulfilled(token) + + const exportData = { + email, + requestDate: dsarRequest.createdAt, + consentRecords: consentRecords.map(({ ipAddress: _ip, ...record }) => ({ + ...record, + createdAt: record.createdAt instanceof Date ? record.createdAt.toISOString() : record.createdAt, + })), + } + + return { + status: 'download', + filename: `my-data-${Date.now()}.json`, + json: JSON.stringify(exportData, null, 2), + } + } + + if (requestType === 'DELETE') { + await deleteConsentRecordsByEmail(email) + await deleteNewsletterConfirmationsByEmail(email) + await markDsarRequestFulfilled(token) + return { status: 'deleted' } + } + + return { status: 'error' } +} + +export const gdpr = { + verifyDsar: defineAction({ + accept: 'json', + input: z.object({ token: z.string().min(1) }), + handler: async (input, context): Promise => { + const { fingerprint } = buildRequestFingerprint({ + route: '/_actions/gdpr/verifyDsar', + request: context.request, + cookies: context.cookies, + clientAddress: context.clientAddress, + }) + + const rateLimitIdentifier = createRateLimitIdentifier('gdpr:dsar:verify', fingerprint) + const { success, reset } = await checkRateLimit(rateLimiters.export, rateLimitIdentifier) + if (!success) { + buildRateLimitError(reset, 'Too many requests') + } + + try { + return await verifyDsarToken(input.token) + } catch (error) { + console.error('[gdpr.verifyDsar] failed:', error) + return { status: 'error' } + } + }, + }), + + consentCreate: defineAction({ + accept: 'json', + input: consentCreateSchema, + handler: async (body, context): Promise => { + const { fingerprint } = buildRequestFingerprint({ + route: '/_actions/gdpr/consentCreate', + request: context.request, + cookies: context.cookies, + clientAddress: context.clientAddress, + }) + + const rateLimitIdentifier = createRateLimitIdentifier('gdpr:consent:post', fingerprint) + const { success, reset } = await checkRateLimit(rateLimiters.consent, rateLimitIdentifier) + if (!success) { + buildRateLimitError(reset) + } + + if (!uuidValidate(body.DataSubjectId)) { + throw new ActionError({ code: 'BAD_REQUEST', message: 'Invalid DataSubjectId' }) + } + + const normalizedEmail = normalizeNullableString(body.email ?? null) + const normalizedPurposes = sanitizePurposes(body.purposes) + const normalizedSource = sanitizeSource(body.source) + const normalizedUserAgent = normalizeUserAgent(body.userAgent) + const normalizedIpAddress = normalizeNullableString(body.ipAddress ?? null) + const normalizedConsentText = normalizeNullableString(body.consentText ?? null) + + const dbRecord = await createConsentRecord({ + dataSubjectId: body.DataSubjectId, + email: normalizedEmail, + purposes: normalizedPurposes, + source: normalizedSource, + userAgent: normalizedUserAgent, + ipAddress: normalizedIpAddress, + privacyPolicyVersion: getPrivacyPolicyVersion(), + consentText: normalizedConsentText, + verified: body.verified ?? false, + }) + + return { + success: true, + record: mapConsentRecord(dbRecord), + } + }, + }), + + consentList: defineAction({ + accept: 'json', + input: consentListSchema, + handler: async (input, context): Promise<{ success: true; records: ConsentResponse['record'][]; hasActive?: boolean; activeRecord?: ConsentResponse['record'] }> => { + const { fingerprint } = buildRequestFingerprint({ + route: '/_actions/gdpr/consentList', + request: context.request, + cookies: context.cookies, + clientAddress: context.clientAddress, + }) + + const rateLimitIdentifier = createRateLimitIdentifier('gdpr:consent:get', fingerprint) + const { success, reset } = await checkRateLimit(rateLimiters.consentRead, rateLimitIdentifier) + if (!success) { + buildRateLimitError(reset) + } + + const { DataSubjectId, purpose } = input + + if (!DataSubjectId || !uuidValidate(DataSubjectId)) { + throw new ActionError({ code: 'BAD_REQUEST', message: 'Valid DataSubjectId required' }) + } + + const fetched = await findConsentRecords(DataSubjectId) + const filteredRecords = purpose ? fetched.filter(record => record.purposes.includes(purpose)) : fetched + const records = filteredRecords.map(mapConsentRecord) + + const response: { + success: true + records: ConsentResponse['record'][] + hasActive?: boolean + activeRecord?: ConsentResponse['record'] + } = { + success: true, + records, + } + + if (purpose) { + response.hasActive = records.length > 0 + if (records[0]) { + response.activeRecord = records[0] + } + } + + return response + }, + }), + + consentDelete: defineAction({ + accept: 'json', + input: consentDeleteSchema, + handler: async (input, context): Promise<{ success: true; deletedCount: number }> => { + const { fingerprint } = buildRequestFingerprint({ + route: '/_actions/gdpr/consentDelete', + request: context.request, + cookies: context.cookies, + clientAddress: context.clientAddress, + }) + + const rateLimitIdentifier = createRateLimitIdentifier('gdpr:consent:delete', fingerprint) + const { success, reset } = await checkRateLimit(rateLimiters.delete, rateLimitIdentifier) + if (!success) { + buildRateLimitError(reset) + } + + if (!uuidValidate(input.DataSubjectId)) { + throw new ActionError({ code: 'BAD_REQUEST', message: 'Valid DataSubjectId required' }) + } + + const deletedCount = await deleteConsentRecords(input.DataSubjectId) + return { success: true, deletedCount } + }, + }), + + requestData: defineAction({ + accept: 'json', + input: dsarRequestSchema, + handler: async (input: DSARRequestInput, context): Promise => { + const { fingerprint } = buildRequestFingerprint({ + route: '/_actions/gdpr/requestData', + request: context.request, + cookies: context.cookies, + clientAddress: context.clientAddress, + }) + + const rateLimitIdentifier = createRateLimitIdentifier('gdpr:dsar:request', fingerprint) + const { success, reset } = await checkRateLimit(rateLimiters.export, rateLimitIdentifier) + if (!success) { + buildRateLimitError(reset, 'Too many requests. Try again later.') + } + + if (!emailValidator.validate(input.email)) { + throw new ActionError({ code: 'BAD_REQUEST', message: 'Invalid email format' }) + } + + const email = input.email.toLowerCase().trim() + const token = crypto.randomUUID() + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) + + const existing = await findActiveRequestByEmail(email, input.requestType) + + if (existing) { + await sendDsarVerificationEmail(email, existing.token, input.requestType) + return { + success: true, + message: 'Verification email sent. Please check your inbox.', + } + } + + await createDsarRequest({ + token, + email, + requestType: input.requestType, + expiresAt, + }) + + await sendDsarVerificationEmail(email, token, input.requestType) + + return { + success: true, + message: + 'Verification email sent. Please check your inbox and click the link to complete your request.', + } + }, + }), + + exportByDataSubjectId: defineAction({ + accept: 'json', + input: z.object({ DataSubjectId: z.string().min(1) }), + handler: async (input, context): Promise<{ success: true; json: string; filename: string }> => { + const { fingerprint } = buildRequestFingerprint({ + route: '/_actions/gdpr/exportByDataSubjectId', + request: context.request, + cookies: context.cookies, + clientAddress: context.clientAddress, + }) + + const rateLimitIdentifier = createRateLimitIdentifier('gdpr:export:get', fingerprint) + const { success, reset } = await checkRateLimit(rateLimiters.export, rateLimitIdentifier) + if (!success) { + buildRateLimitError(reset) + } + + if (!uuidValidate(input.DataSubjectId)) { + throw new ActionError({ code: 'BAD_REQUEST', message: 'Invalid DataSubjectId' }) + } + + const consentRecords = await findConsentRecords(input.DataSubjectId) + const exportData = consentRecords.map(record => ({ + id: record.id, + 'data_subject_id': record.dataSubjectId, + email: record.email, + purposes: record.purposes, + source: record.source, + 'user_agent': record.userAgent, + 'privacy_policy_version': record.privacyPolicyVersion, + 'consent_text': record.consentText, + verified: record.verified, + 'created_at': record.createdAt.toISOString(), + })) + + return { + success: true, + filename: `my-data-${Date.now()}.json`, + json: JSON.stringify(exportData, null, 2), + } + }, + }), +} diff --git a/src/actions/index.ts b/src/actions/index.ts new file mode 100644 index 000000000..66b0755e7 --- /dev/null +++ b/src/actions/index.ts @@ -0,0 +1,11 @@ +import { contact } from './contact/responder' +import { downloads } from './downloads/responder' +import { gdpr } from './gdpr/responder' +import { newsletter } from './newsletter/responder' + +export const server = { + contact, + downloads, + gdpr, + newsletter, +} diff --git a/src/pages/api/newsletter/_token.ts b/src/actions/newsletter/action.ts similarity index 64% rename from src/pages/api/newsletter/_token.ts rename to src/actions/newsletter/action.ts index 02978436a..4e2cbec06 100644 --- a/src/pages/api/newsletter/_token.ts +++ b/src/actions/newsletter/action.ts @@ -1,53 +1,33 @@ -/** - * Newsletter subscription token management for double opt-in - * Generates and validates confirmation tokens for GDPR-compliant newsletter signups - */ - import { randomUUID } from 'node:crypto' import { and, db, eq, isNull, newsletterConfirmations } from 'astro:db' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' +import { ActionsFunctionError } from '@actions/_errors/ActionsFunctionError' -/** - * Pending subscription data stored temporarily until confirmed - */ export interface PendingSubscription { email: string firstName?: string | undefined DataSubjectId: string token: string - createdAt: string // ISO 8601 - expiresAt: string // ISO 8601 - 24 hours from creation - consentTimestamp: string // ISO 8601 + createdAt: string + expiresAt: string + consentTimestamp: string userAgent: string - ipAddress?: string | undefined // Optional, for fraud prevention only + ipAddress?: string | undefined verified: boolean source: 'newsletter_form' | 'contact_form' } -/** - * In-memory storage for pending subscriptions - * In production, use Redis, database, or Vercel KV - */ const pendingSubscriptions = new Map() -/** - * Generate cryptographically secure token - * Uses Web Crypto API for secure random generation - */ export function generateConfirmationToken(): string { - // Generate 32 random bytes and encode as base64url (URL-safe) const array = new Uint8Array(32) crypto.getRandomValues(array) - return Buffer.from(array).toString('base64') + return Buffer.from(array) + .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, '') } -/** - * Create and store a pending subscription - * Returns the confirmation token to be sent via email - */ export async function createPendingSubscription(data: { email: string firstName?: string @@ -58,7 +38,7 @@ export async function createPendingSubscription(data: { }): Promise { const token = generateConfirmationToken() const now = new Date() - const expiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000) // 24 hours + const expiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000) const pending: PendingSubscription = { email: data.email.toLowerCase().trim(), @@ -90,47 +70,31 @@ export async function createPendingSubscription(data: { createdAt: now, }) } catch (error) { - throw new ApiFunctionError({ + throw new ActionsFunctionError({ message: 'Failed to create subscription confirmation', cause: error, code: 'NEWSLETTER_TOKEN_CREATE_FAILED', status: 500, - route: '/api/newsletter', + route: 'actions:newsletter', operation: 'createPendingSubscription', }) } - // Also keep in memory for backward compatibility (for now) pendingSubscriptions.set(token, pending) - - // Clean up expired tokens (simple garbage collection) cleanExpiredTokens() - return token } -/** - * Validate and retrieve pending subscription by token - * Returns null if token is invalid or expired - */ -export async function validateToken( - token: string, -): Promise { +export async function validateToken(token: string): Promise { const [dbRecord] = await db .select() .from(newsletterConfirmations) - .where( - and( - eq(newsletterConfirmations.token, token), - isNull(newsletterConfirmations.confirmedAt), - ), - ) + .where(and(eq(newsletterConfirmations.token, token), isNull(newsletterConfirmations.confirmedAt))) .limit(1) if (dbRecord) { const now = new Date() const expiresAt = new Date(dbRecord.expiresAt) - if (now > expiresAt) { return null } @@ -150,24 +114,19 @@ export async function validateToken( } } - // Fallback to in-memory (for backward compatibility) const pending = pendingSubscriptions.get(token) - if (!pending) { return null } - // Check if expired const now = new Date() const expiresAt = new Date(pending.expiresAt) if (now > expiresAt) { - // Token expired, remove it pendingSubscriptions.delete(token) return null } - // Check if already verified if (pending.verified) { return null } @@ -175,40 +134,21 @@ export async function validateToken( return pending } -/** - * Mark subscription as verified and remove from pending - * Returns the subscription data for processing - */ -export async function confirmSubscription( - token: string, -): Promise { +export async function confirmSubscription(token: string): Promise { const pending = await validateToken(token) - if (!pending) { return null } - await db - .update(newsletterConfirmations) - .set({ confirmedAt: new Date() }) - .where(eq(newsletterConfirmations.token, token)) + await db.update(newsletterConfirmations).set({ confirmedAt: new Date() }).where(eq(newsletterConfirmations.token, token)) - // Mark as verified pending.verified = true - - // Remove from in-memory pending (one-time use token) pendingSubscriptions.delete(token) - return pending } -/** - * Clean up expired tokens from storage - * Should be called periodically or on each new subscription - */ function cleanExpiredTokens(): void { const now = new Date() - for (const [token, pending] of pendingSubscriptions.entries()) { const expiresAt = new Date(pending.expiresAt) if (now > expiresAt) { @@ -217,10 +157,6 @@ function cleanExpiredTokens(): void { } } -/** - * Get all pending subscriptions (for testing/debugging) - * Should be removed or protected in production - */ export function getPendingCount(): number { return pendingSubscriptions.size } diff --git a/src/pages/api/newsletter/_email.ts b/src/actions/newsletter/entities.ts similarity index 81% rename from src/pages/api/newsletter/_email.ts rename to src/actions/newsletter/entities.ts index 19512b3e0..a29a2d640 100644 --- a/src/pages/api/newsletter/_email.ts +++ b/src/actions/newsletter/entities.ts @@ -1,27 +1,16 @@ -/** - * Newsletter confirmation email service - * Sends double opt-in confirmation emails using Resend - */ - import { Resend } from 'resend' -import { getResendApiKey, isDev, isTest } from '@pages/api/_environment/environmentApi' -import { getSiteUrl } from '@pages/api/_environment/siteUrlApi' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' +import { getResendApiKey, isDev, isTest } from '@actions/_environment/environmentActions' +import { getSiteUrl } from '@actions/_environment/siteUrlActions' +import { ActionsFunctionError } from '@actions/_errors/ActionsFunctionError' -/** - * Initialize Resend client - */ function getResendClient(): Resend { return new Resend(getResendApiKey()) } -/** - * Generate the HTML content for the confirmation email - */ function generateConfirmationEmailHtml( firstName: string | undefined, confirmUrl: string, - expiresIn: string = '24 hours' + expiresIn: string = '24 hours', ): string { const greeting = firstName ? `Hi ${firstName}` : 'Hello' @@ -141,13 +130,10 @@ function generateConfirmationEmailHtml( `.trim() } -/** - * Generate plain text version of the confirmation email - */ function generateConfirmationEmailText( firstName: string | undefined, confirmUrl: string, - expiresIn: string = '24 hours' + expiresIn: string = '24 hours', ): string { const greeting = firstName ? `Hi ${firstName}` : 'Hello' @@ -181,25 +167,11 @@ Unsubscribe: ${getSiteUrl()}/privacy#unsubscribe `.trim() } -/** - * Send confirmation email to subscriber - * - * @param email - Subscriber's email address - * @param token - Confirmation token - * @param firstName - Optional subscriber first name for personalization - * @returns Promise that resolves when email is sent - * @throws {Error} If Resend API key is not configured or email fails to send - */ -export async function sendConfirmationEmail( - email: string, - token: string, - firstName?: string -): Promise { +export async function sendConfirmationEmail(email: string, token: string, firstName?: string): Promise { const siteUrl = getSiteUrl() const confirmUrl = `${siteUrl}/newsletter/confirm/${token}` const expiresIn = '24 hours' - // Skip actual email sending in dev/test (handled by Astro Actions later) if (isDev() || isTest()) { console.log('[DEV/TEST MODE] Newsletter confirmation email would be sent:', { email, token }) return @@ -217,17 +189,6 @@ export async function sendConfirmationEmail( ], } - const handleSendError = (error: unknown) => { - console.error('[Newsletter Email] Error sending confirmation:', error) - throw new ApiFunctionError(error, { - message: 'Failed to send confirmation email. Please try again later.', - code: 'NEWSLETTER_CONFIRMATION_EMAIL_FAILED', - status: 502, - route: '/api/newsletter', - operation: 'sendConfirmationEmail' - }) - } - const resend = getResendClient() try { @@ -235,35 +196,27 @@ export async function sendConfirmationEmail( if (result.error) { console.error('[Newsletter Email] Failed to send confirmation:', result.error) - throw new ApiFunctionError({ + throw new ActionsFunctionError({ message: `Failed to send confirmation email: ${result.error.message}`, code: 'NEWSLETTER_CONFIRMATION_EMAIL_FAILED', status: 502, - route: '/api/newsletter', - operation: 'sendConfirmationEmail' + route: 'actions:newsletter', + operation: 'sendConfirmationEmail', }) } - - console.log('[Newsletter Email] Confirmation sent successfully:', { - email, - messageId: result.data?.id, - }) } catch (error) { - handleSendError(error) + console.error('[Newsletter Email] Error sending confirmation:', error) + throw new ActionsFunctionError(error, { + message: 'Failed to send confirmation email. Please try again later.', + code: 'NEWSLETTER_CONFIRMATION_EMAIL_FAILED', + status: 502, + route: 'actions:newsletter', + operation: 'sendConfirmationEmail', + }) } } -/** - * Send welcome email after subscription is confirmed - * This is sent after the user clicks the confirmation link - * - * @param email - Subscriber's email address - * @param firstName - Optional subscriber first name for personalization - */ -export async function sendWelcomeEmail( - email: string, - firstName?: string -): Promise { +export async function sendWelcomeEmail(email: string, firstName?: string): Promise { if (isDev() || isTest()) { console.log('[DEV/TEST MODE] Newsletter welcome email would be sent:', { email }) return @@ -388,26 +341,15 @@ Questions? Reply to this email or contact us at hello@webstackbuilders.com `.trim() const resendPayload = { - from: 'Webstack Builders ', - to: email, - subject: '🎉 Welcome to Webstack Builders!', - html, - text, - tags: [ - { name: 'type', value: 'newsletter-welcome' }, - { name: 'flow', value: 'post-confirmation' }, - ], - } - - const handleSendError = (error: unknown) => { - console.error('[Newsletter Email] Error sending welcome email:', error) - throw new ApiFunctionError(error, { - message: 'Failed to send welcome email. Please try again later.', - code: 'NEWSLETTER_WELCOME_EMAIL_FAILED', - status: 502, - route: '/api/newsletter', - operation: 'sendWelcomeEmail' - }) + from: 'Webstack Builders ', + to: email, + subject: '🎉 Welcome to Webstack Builders!', + html, + text, + tags: [ + { name: 'type', value: 'newsletter-welcome' }, + { name: 'flow', value: 'post-confirmation' }, + ], } try { @@ -415,20 +357,22 @@ Questions? Reply to this email or contact us at hello@webstackbuilders.com if (result.error) { console.error('[Newsletter Email] Failed to send welcome email:', result.error) - throw new ApiFunctionError({ + throw new ActionsFunctionError({ message: `Failed to send welcome email: ${result.error.message}`, code: 'NEWSLETTER_WELCOME_EMAIL_FAILED', status: 502, - route: '/api/newsletter', - operation: 'sendWelcomeEmail' + route: 'actions:newsletter', + operation: 'sendWelcomeEmail', }) } - - console.log('[Newsletter Email] Welcome email sent successfully:', { - email, - messageId: result.data?.id, - }) } catch (error) { - handleSendError(error) + console.error('[Newsletter Email] Error sending welcome email:', error) + throw new ActionsFunctionError(error, { + message: 'Failed to send welcome email. Please try again later.', + code: 'NEWSLETTER_WELCOME_EMAIL_FAILED', + status: 502, + route: 'actions:newsletter', + operation: 'sendWelcomeEmail', + }) } } diff --git a/src/actions/newsletter/responder.ts b/src/actions/newsletter/responder.ts new file mode 100644 index 000000000..4d941d5db --- /dev/null +++ b/src/actions/newsletter/responder.ts @@ -0,0 +1,237 @@ +import emailValidator from 'email-validator' +import { v4 as uuidv4, validate as uuidValidate } from 'uuid' +import { ActionError, defineAction } from 'astro:actions' +import { z } from 'astro:schema' +import { getConvertkitApiKey, getPrivacyPolicyVersion, isDev, isTest } from '@actions/_environment/environmentActions' +import { checkRateLimit, rateLimiters } from '@actions/_utils/rateLimit' +import { buildRequestFingerprint, createRateLimitIdentifier } from '@actions/_utils/requestContext' +import { createConsentRecord, markConsentRecordsVerified } from '@actions/gdpr/domain/consentStore' +import { createPendingSubscription, confirmSubscription } from './action' +import { sendConfirmationEmail, sendWelcomeEmail } from '@actions/newsletter/entities' + +type NewsletterFormData = { + email: string + firstName?: string + consentGiven?: boolean + DataSubjectId?: string +} + +type ConvertKitSubscriber = { + 'email_address': string + 'first_name'?: string + state?: 'active' | 'inactive' + fields?: Record +} + +type ConvertKitResponse = { + subscriber: { + id: number + 'first_name': string | null + 'email_address': string + state: string + 'created_at': string + fields: Record + } +} + +type ConvertKitErrorResponse = { + errors: string[] +} + +function validateEmail(email: string): string { + if (!email) { + throw new ActionError({ code: 'BAD_REQUEST', message: 'Email address is required.' }) + } + + if (email.length > 254) { + throw new ActionError({ code: 'BAD_REQUEST', message: 'Email address is too long' }) + } + + if (!emailValidator.validate(email)) { + throw new ActionError({ code: 'BAD_REQUEST', message: 'Email address is invalid' }) + } + + return email.trim().toLowerCase() +} + +export async function subscribeToConvertKit(data: NewsletterFormData): Promise { + if (isDev() || isTest()) { + console.log('[DEV/TEST MODE] Newsletter subscription would be created:', { email: data.email }) + return { + subscriber: { + id: 999999, + state: 'active', + 'email_address': data.email, + 'first_name': data.firstName || null, + 'created_at': new Date().toISOString(), + fields: {}, + }, + } + } + + const subscriberData: ConvertKitSubscriber = { + 'email_address': data.email, + state: 'active', + } + + if (data.firstName) { + subscriberData['first_name'] = data.firstName.trim() + } + + const response = await fetch('https://api.kit.com/v4/subscribers', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Kit-Api-Key': getConvertkitApiKey(), + }, + body: JSON.stringify(subscriberData), + }) + + const responseData = await response.json() + + if (response.status === 401) { + const errorData = responseData as ConvertKitErrorResponse + console.error('ConvertKit API authentication failed:', errorData.errors) + throw new ActionError({ + code: 'BAD_GATEWAY', + message: 'Newsletter service configuration error. Please contact support.', + }) + } + + if (response.status === 422) { + const errorData = responseData as ConvertKitErrorResponse + throw new ActionError({ code: 'BAD_REQUEST', message: errorData.errors[0] || 'Invalid email address' }) + } + + if (response.status === 200 || response.status === 201 || response.status === 202) { + return responseData as ConvertKitResponse + } + + throw new ActionError({ code: 'BAD_GATEWAY', message: 'An unexpected error occurred. Please try again later.' }) +} + +const subscribeSchema = z.object({ + email: z.string(), + firstName: z.string().optional(), + consentGiven: z.boolean().optional(), + DataSubjectId: z.string().optional(), +}) + +const confirmSchema = z.object({ + token: z.string().min(1), +}) + +export const newsletter = { + subscribe: defineAction({ + accept: 'json', + input: subscribeSchema, + handler: async ( + body: z.infer, + context, + ): Promise<{ success: true; message: string; requiresConfirmation: true }> => { + const { fingerprint } = buildRequestFingerprint({ + route: '/_actions/newsletter/subscribe', + request: context.request, + cookies: context.cookies, + clientAddress: context.clientAddress, + }) + + const rateLimitIdentifier = createRateLimitIdentifier('newsletter:consent', fingerprint) + const { success, reset } = await checkRateLimit(rateLimiters.consent, rateLimitIdentifier) + + if (!success) { + const retryAfterMs = typeof reset === 'number' ? Math.max(0, reset - Date.now()) : 0 + const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000)) + throw new ActionError({ code: 'TOO_MANY_REQUESTS', message: `Try again in ${retryAfterSeconds}s` }) + } + + const validatedEmail = validateEmail(body.email) + + if (!body.consentGiven) { + throw new ActionError({ + code: 'BAD_REQUEST', + message: 'You must consent to receive marketing emails to subscribe.', + }) + } + + const userAgent = context.request.headers.get('user-agent') || 'unknown' + + let subjectId = body.DataSubjectId + if (!subjectId) { + subjectId = uuidv4() + } else if (!uuidValidate(subjectId)) { + throw new ActionError({ code: 'BAD_REQUEST', message: 'Invalid DataSubjectId format' }) + } + + await createConsentRecord({ + dataSubjectId: subjectId, + email: validatedEmail, + purposes: ['marketing'], + source: 'newsletter_form', + userAgent, + ipAddress: context.clientAddress && context.clientAddress !== 'unknown' ? context.clientAddress : null, + privacyPolicyVersion: getPrivacyPolicyVersion(), + consentText: null, + verified: false, + }) + + const token = await createPendingSubscription({ + email: validatedEmail, + ...(body.firstName && { firstName: body.firstName }), + DataSubjectId: subjectId, + userAgent, + ...(context.clientAddress && context.clientAddress !== 'unknown' && { ipAddress: context.clientAddress }), + source: 'newsletter_form', + }) + + await sendConfirmationEmail(validatedEmail, token, body.firstName) + + return { + success: true, + message: 'Please check your email to confirm your subscription.', + requiresConfirmation: true, + } + }, + }), + + confirm: defineAction({ + accept: 'json', + input: confirmSchema, + handler: async (input): Promise<{ success: boolean; status: 'success' | 'expired'; email?: string; message: string }> => { + const token = input.token + const subscription = await confirmSubscription(token) + + if (!subscription) { + return { + success: false, + status: 'expired', + message: 'This confirmation link has expired or been used already.', + } + } + + await markConsentRecordsVerified(subscription.email, subscription.DataSubjectId) + + try { + await sendWelcomeEmail(subscription.email, subscription.firstName) + } catch (emailError) { + console.error('[newsletter.confirm] welcome email failed:', emailError) + } + + try { + await subscribeToConvertKit({ + email: subscription.email, + ...(subscription.firstName ? { firstName: subscription.firstName } : {}), + }) + } catch (convertKitError) { + console.error('[newsletter.confirm] convertkit subscribe failed:', convertKitError) + } + + return { + success: true, + status: 'success', + email: subscription.email, + message: 'Your subscription has been confirmed!', + } + }, + }), +} diff --git a/src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts b/src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts index a985589ba..399269e03 100644 --- a/src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts +++ b/src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts @@ -7,6 +7,16 @@ import type { NewsletterFormElement } from '@components/CallToAction/Newsletter/ import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' import { executeRender } from '@test/unit/helpers/litRuntime' +const newsletterSubscribeMock = vi.fn() + +vi.mock('astro:actions', () => ({ + actions: { + newsletter: { + subscribe: newsletterSubscribeMock, + }, + }, +})) + type NewsletterComponentModule = WebComponentModule const flushPromises = async () => { @@ -45,17 +55,13 @@ const getElements = (root: NewsletterFormElement) => { describe('NewsletterFormElement web component', () => { let container: AstroContainer - let fetchMock: ReturnType - const originalFetch = globalThis.fetch beforeEach(async () => { - fetchMock = vi.fn() - globalThis.fetch = fetchMock as unknown as typeof fetch + newsletterSubscribeMock.mockReset() container = await AstroContainer.create() }) afterEach(() => { - globalThis.fetch = originalFetch vi.restoreAllMocks() }) @@ -128,9 +134,8 @@ describe('NewsletterFormElement web component', () => { }) test('submits to the newsletter API and shows success feedback', async () => { - fetchMock.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ success: true, message: 'Subscribed successfully!' }), + newsletterSubscribeMock.mockResolvedValueOnce({ + data: { success: true, message: 'Subscribed successfully!' }, }) await renderNewsletter(async ({ elements }) => { @@ -143,13 +148,7 @@ describe('NewsletterFormElement web component', () => { elements.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) await flushPromises() - expect(fetchMock).toHaveBeenCalledWith('/api/newsletter', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ email: 'test@example.com', consentGiven: true }), - }) + expect(newsletterSubscribeMock).toHaveBeenCalledWith({ email: 'test@example.com', consentGiven: true }) expect(elements.message.textContent).toBe('Subscribed successfully!') expect(elements.message.getAttribute('role')).toBe('status') expect(elements.message.getAttribute('aria-live')).toBe('polite') @@ -162,9 +161,8 @@ describe('NewsletterFormElement web component', () => { }) test('handles API error responses gracefully', async () => { - fetchMock.mockResolvedValueOnce({ - ok: false, - json: () => Promise.resolve({ success: false, error: 'Subscription failed' }), + newsletterSubscribeMock.mockResolvedValueOnce({ + error: { message: 'Subscription failed' }, }) await renderNewsletter(async ({ elements }) => { @@ -174,13 +172,13 @@ describe('NewsletterFormElement web component', () => { elements.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) await flushPromises() - expect(fetchMock).toHaveBeenCalled() + expect(newsletterSubscribeMock).toHaveBeenCalled() expect(elements.message.textContent).toBe('Subscription failed') }) }) test('shows a network error message when fetch rejects', async () => { - fetchMock.mockRejectedValueOnce(new TestError('Network error')) + newsletterSubscribeMock.mockRejectedValueOnce(new TestError('Network error')) await renderNewsletter(async ({ elements }) => { elements.emailInput.value = 'test@example.com' @@ -189,7 +187,7 @@ describe('NewsletterFormElement web component', () => { elements.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) await flushPromises() - expect(fetchMock).toHaveBeenCalled() + expect(newsletterSubscribeMock).toHaveBeenCalled() expect(elements.message.textContent).toBe('Network error. Please check your connection and try again.') }) }) diff --git a/src/components/CallToAction/Newsletter/client/index.ts b/src/components/CallToAction/Newsletter/client/index.ts index 52382dd71..12f48ae6d 100644 --- a/src/components/CallToAction/Newsletter/client/index.ts +++ b/src/components/CallToAction/Newsletter/client/index.ts @@ -6,6 +6,7 @@ import { LitElement } from 'lit' import emailValidator from 'email-validator' +import { actions } from 'astro:actions' import { addScriptBreadcrumb, ClientScriptError } from '@components/scripts/errors' import { handleScriptError } from '@components/scripts/errors/handler' import { getNewsletterElements } from './selectors' @@ -256,22 +257,14 @@ export class NewsletterFormElement extends LitElement { this.showMessage('Sending confirmation email...', 'info') try { - const response = await fetch('/api/newsletter', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email, - consentGiven, - }), + const result = await actions.newsletter.subscribe({ + email, + consentGiven, }) - const data = await response.json() - - if (response.ok && data.success) { + if (result.data?.success) { this.showMessage( - data.message || 'Check your email! Click the confirmation link to complete your subscription.', + result.data.message || 'Check your email! Click the confirmation link to complete your subscription.', 'success' ) this.submitButton.dispatchEvent(new CustomEvent('confetti:fire', { bubbles: true, composed: true })) @@ -279,7 +272,7 @@ export class NewsletterFormElement extends LitElement { this.setFieldInvalid(this.emailInput, false) this.setFieldInvalid(this.consentCheckbox, false) } else { - this.showMessage(data.error || 'Failed to subscribe. Please try again.', 'error') + this.showMessage(result.error?.message || 'Failed to subscribe. Please try again.', 'error') } } catch (error) { handleScriptError(error, { scriptName: 'NewsletterFormElement', operation: 'apiSubmission' }) diff --git a/src/components/Forms/Contact/client/@types/index.ts b/src/components/Forms/Contact/client/@types/index.ts index 6d8a3f011..0b9d886bc 100644 --- a/src/components/Forms/Contact/client/@types/index.ts +++ b/src/components/Forms/Contact/client/@types/index.ts @@ -34,5 +34,4 @@ export interface ContactFormConfig { maxCharacters: number warningThreshold: number errorThreshold: number - apiEndpoint: string } diff --git a/src/components/Forms/Contact/client/__tests__/formSubmission.spec.ts b/src/components/Forms/Contact/client/__tests__/formSubmission.spec.ts index 86ab31201..1b73aa50f 100644 --- a/src/components/Forms/Contact/client/__tests__/formSubmission.spec.ts +++ b/src/components/Forms/Contact/client/__tests__/formSubmission.spec.ts @@ -1,6 +1,16 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { TestError } from '@test/errors' -import { renderContactForm, type RenderContactFormContext } from './testUtils' +import type { RenderContactFormContext } from './testUtils' + +const contactSubmitMock = vi.fn() + +vi.mock('astro:actions', () => ({ + actions: { + contact: { + submit: contactSubmitMock, + }, + }, +})) vi.mock('@components/scripts/errors', () => ({ addScriptBreadcrumb: vi.fn(), @@ -12,9 +22,16 @@ vi.mock('@components/scripts/errors/handler', () => ({ const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0)) +let renderContactForm: typeof import('./testUtils').renderContactForm + +beforeAll(async () => { + ;({ renderContactForm } = await import('./testUtils')) +}) + describe('ContactForm submission', () => { beforeEach(() => { vi.clearAllMocks() + contactSubmitMock.mockReset() }) const fillValidFields = (context: RenderContactFormContext): void => { @@ -33,17 +50,13 @@ describe('ContactForm submission', () => { it('shows error banner and skips request when validations fail', async () => { await renderContactForm(async ({ elements, window }) => { - const fetchSpy = vi.spyOn(globalThis, 'fetch') - const submitEvent = new window.Event('submit', { bubbles: true, cancelable: true }) elements.form.dispatchEvent(submitEvent) await flushPromises() - expect(fetchSpy).not.toHaveBeenCalled() + expect(contactSubmitMock).not.toHaveBeenCalled() expect(elements.formErrorBanner.classList.contains('hidden')).toBe(false) - - fetchSpy.mockRestore() }) }) @@ -51,10 +64,9 @@ describe('ContactForm submission', () => { await renderContactForm(async context => { fillValidFields(context) - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ - ok: true, - json: async () => ({ success: true }), - } as Response) + contactSubmitMock.mockResolvedValue({ + data: { success: true, message: 'Thank you for your message. We will get back to you soon!' }, + }) let confettiEvent: Event | undefined context.elements.submitBtn.addEventListener('confetti:fire', (event) => { @@ -66,11 +78,8 @@ describe('ContactForm submission', () => { await flushPromises() - expect(fetchSpy).toHaveBeenCalledTimes(1) - expect(fetchSpy).toHaveBeenCalledWith( - '/api/contact', - expect.objectContaining({ method: 'POST' }), - ) + expect(contactSubmitMock).toHaveBeenCalledTimes(1) + expect(contactSubmitMock.mock.calls[0]?.[0]).toBeInstanceOf(FormData) expect(context.elements.messages.style.display).toBe('block') expect(context.elements.successMessage.classList.contains('hidden')).toBe(false) expect(context.elements.errorMessage.classList.contains('hidden')).toBe(true) @@ -87,8 +96,6 @@ describe('ContactForm submission', () => { expect(confettiEvent?.target).toBe(context.elements.submitBtn) expect(confettiEvent?.bubbles).toBe(true) expect((confettiEvent as CustomEvent)?.composed).toBe(true) - - fetchSpy.mockRestore() }) }) @@ -97,17 +104,14 @@ describe('ContactForm submission', () => { fillValidFields(context) context.elements.charCount.textContent = '42' - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ - ok: false, - json: async () => ({ success: false, message: 'Server error' }), - } as Response) + contactSubmitMock.mockResolvedValue({ error: { message: 'Server error' } }) const submitEvent = new context.window.Event('submit', { bubbles: true, cancelable: true }) context.elements.form.dispatchEvent(submitEvent) await flushPromises() - expect(fetchSpy).toHaveBeenCalled() + expect(contactSubmitMock).toHaveBeenCalled() expect(context.elements.messages.style.display).toBe('block') expect(context.elements.errorMessage.classList.contains('hidden')).toBe(false) expect(context.elements.errorMessage.style.display).toBe('flex') @@ -115,8 +119,6 @@ describe('ContactForm submission', () => { expect(context.elements.successMessage.classList.contains('hidden')).toBe(true) expect(context.elements.charCount.textContent).toBe('42') expect(context.elements.submitBtn.disabled).toBe(false) - - fetchSpy.mockRestore() }) }) }) diff --git a/src/components/Forms/Contact/client/__tests__/utils.spec.ts b/src/components/Forms/Contact/client/__tests__/utils.spec.ts index 782254604..b609d469d 100644 --- a/src/components/Forms/Contact/client/__tests__/utils.spec.ts +++ b/src/components/Forms/Contact/client/__tests__/utils.spec.ts @@ -8,7 +8,6 @@ const baseConfig: ContactFormConfig = { maxCharacters: 2000, warningThreshold: 1500, errorThreshold: 1800, - apiEndpoint: '/api/contact', } describe('ContactForm utils', () => { diff --git a/src/components/Forms/Contact/client/formSubmission.ts b/src/components/Forms/Contact/client/formSubmission.ts index 865fc1486..463ac8b4d 100644 --- a/src/components/Forms/Contact/client/formSubmission.ts +++ b/src/components/Forms/Contact/client/formSubmission.ts @@ -2,8 +2,9 @@ import { addScriptBreadcrumb } from '@components/scripts/errors' import { handleScriptError } from '@components/scripts/errors/handler' import { hideErrorBanner, showErrorBanner, clearFieldFeedback, type LabelController } from './feedback' import { validateGenericFields, validateNameField, validateMessageField } from './validation' -import type { ContactFormConfig, ContactFormElements } from './@types' +import type { ContactFormElements } from './@types' import { validateEmailField } from './email' +import { actions } from 'astro:actions' interface SubmissionControllers { labelController: LabelController @@ -62,7 +63,6 @@ const resetFormState = (elements: ContactFormElements, controllers: SubmissionCo export const initFormSubmission = ( elements: ContactFormElements, - config: ContactFormConfig, controllers: SubmissionControllers, ): void => { const context = { scriptName: 'ContactFormElement', operation: 'handleFormSubmission' } @@ -85,14 +85,9 @@ export const initFormSubmission = ( try { const formData = new FormData(elements.form) - const response = await fetch(config.apiEndpoint, { - method: 'POST', - body: formData, - }) + const result = await actions.contact.submit(formData) - const result = await response.json() - - if (response.ok && result.success) { + if (result.data?.success) { showSuccessMessage(elements) elements.submitBtn.dispatchEvent( @@ -104,7 +99,10 @@ export const initFormSubmission = ( resetFormState(elements, controllers) } else { - showErrorMessage(elements, result.message || 'An error occurred while sending your message.') + showErrorMessage( + elements, + result.error?.message || result.data?.message || 'An error occurred while sending your message.', + ) } } catch (error) { handleScriptError(error, context) diff --git a/src/components/Forms/Contact/client/index.ts b/src/components/Forms/Contact/client/index.ts index 7ad610fb9..1bd88b579 100644 --- a/src/components/Forms/Contact/client/index.ts +++ b/src/components/Forms/Contact/client/index.ts @@ -28,7 +28,6 @@ export class ContactFormElement extends LitElement { maxCharacters: 2000, warningThreshold: 1500, errorThreshold: 1800, - apiEndpoint: '/api/contact', } override createRenderRoot() { @@ -58,7 +57,7 @@ export class ContactFormElement extends LitElement { initNameLengthHandler(elements.fields.name) initMssgLengthHandler(elements.fields.message) initGenericValidation(elements.form) - initFormSubmission(elements, this.config, { + initFormSubmission(elements, { labelController: this.labelController, }) this.setViewTransitionsHandlers() diff --git a/src/components/Forms/Download/client/__tests__/index.spec.ts b/src/components/Forms/Download/client/__tests__/index.spec.ts index faaf0db39..36ec63d26 100644 --- a/src/components/Forms/Download/client/__tests__/index.spec.ts +++ b/src/components/Forms/Download/client/__tests__/index.spec.ts @@ -1,5 +1,15 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { renderDownloadForm, type DownloadFormElements } from './testUtils' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { DownloadFormElements } from './testUtils' + +const downloadsSubmitMock = vi.fn() + +vi.mock('astro:actions', () => ({ + actions: { + downloads: { + submit: downloadsSubmitMock, + }, + }, +})) // Mock the logger to suppress error output in tests vi.mock('@lib/logger', () => ({ @@ -12,6 +22,12 @@ vi.mock('@lib/logger', () => ({ })) const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0)) +let renderDownloadForm: typeof import('./testUtils').renderDownloadForm + +beforeAll(async () => { + ;({ renderDownloadForm } = await import('./testUtils')) +}) + const defaultFormValues = { firstName: 'Jane', lastName: 'Doe', @@ -38,21 +54,14 @@ const submitForm = (window: Window & typeof globalThis, form: HTMLFormElement) = form.dispatchEvent(submitEvent) } -const successfulResponse = (): Response => - ({ - ok: true, - json: async () => ({ success: true }), - } as Response) - describe('download-form web component', () => { afterEach(() => { vi.restoreAllMocks() + downloadsSubmitMock.mockReset() }) it('does not submit when native form validation fails', async () => { await renderDownloadForm(async ({ elements, window }) => { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(successfulResponse()) - vi.spyOn(elements.form, 'checkValidity').mockReturnValue(false) vi.spyOn(elements.form, 'reportValidity').mockReturnValue(false) @@ -60,44 +69,31 @@ describe('download-form web component', () => { submitForm(window, elements.form) await flushPromises() - expect(fetchSpy).not.toHaveBeenCalled() + expect(downloadsSubmitMock).not.toHaveBeenCalled() expect(elements.firstName.getAttribute('aria-invalid')).toBe('true') expect(elements.statusDiv.classList.contains('hidden')).toBe(false) expect(elements.statusDiv.classList.contains('error')).toBe(true) expect(elements.statusDiv.getAttribute('role')).toBe('alert') expect(elements.statusDiv.getAttribute('aria-live')).toBe('assertive') - - fetchSpy.mockRestore() }) }) - it('submits download requests via fetch', async () => { + it('submits download requests via actions', async () => { await renderDownloadForm(async ({ elements, window }) => { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(successfulResponse()) + downloadsSubmitMock.mockResolvedValue({ data: { success: true } }) const payload = fillDownloadForm(elements) submitForm(window, elements.form) await flushPromises() - expect(fetchSpy).toHaveBeenCalledWith( - '/api/downloads/submit', - expect.objectContaining({ - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }), - ) - - fetchSpy.mockRestore() + expect(downloadsSubmitMock).toHaveBeenCalledWith(payload) }) }) it('shows success message and reveals download button', async () => { await renderDownloadForm(async ({ elements, window }) => { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(successfulResponse()) + downloadsSubmitMock.mockResolvedValue({ data: { success: true } }) fillDownloadForm(elements) submitForm(window, elements.form) @@ -113,14 +109,12 @@ describe('download-form web component', () => { expect(elements.firstName.value).toBe('') expect(elements.lastName.value).toBe('') expect(elements.workEmail.value).toBe('') - - fetchSpy.mockRestore() }) }) it('dispatches a confetti:fire event from the submit button on success', async () => { await renderDownloadForm(async ({ elements, window }) => { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(successfulResponse()) + downloadsSubmitMock.mockResolvedValue({ data: { success: true } }) fillDownloadForm(elements) let confettiEvent: Event | undefined @@ -135,23 +129,18 @@ describe('download-form web component', () => { expect(confettiEvent?.target).toBe(elements.submitButton) expect(confettiEvent?.bubbles).toBe(true) expect((confettiEvent as CustomEvent)?.composed).toBe(true) - - fetchSpy.mockRestore() }) }) it('displays error state when API fails', async () => { await renderDownloadForm(async ({ elements, window }) => { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ - ok: false, - json: async () => ({ message: 'Server error' }), - } as Response) + downloadsSubmitMock.mockResolvedValue({ error: { message: 'Server error' } }) fillDownloadForm(elements) submitForm(window, elements.form) await flushPromises() - expect(fetchSpy).toHaveBeenCalled() + expect(downloadsSubmitMock).toHaveBeenCalled() expect(elements.statusDiv.classList.contains('hidden')).toBe(false) expect(elements.statusDiv.classList.contains('error')).toBe(true) expect(elements.statusDiv.textContent).toContain('There was an error processing your request') @@ -159,18 +148,16 @@ describe('download-form web component', () => { expect(elements.statusDiv.getAttribute('aria-live')).toBe('assertive') expect(elements.downloadButtonWrapper.classList.contains('hidden')).toBe(true) expect(elements.submitButton.classList.contains('hidden')).toBe(false) - - fetchSpy.mockRestore() }) }) it('disables submit button while request is pending', async () => { await renderDownloadForm(async ({ elements, window }) => { - let resolveFetch: (() => void) | undefined - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation( + let resolveSubmit: (() => void) | undefined + downloadsSubmitMock.mockImplementation( () => new Promise(resolve => { - resolveFetch = () => resolve(successfulResponse()) + resolveSubmit = () => resolve({ data: { success: true } }) }), ) @@ -180,14 +167,12 @@ describe('download-form web component', () => { expect(elements.submitButton.disabled).toBe(true) expect(elements.submitButton.textContent).toBe('Processing...') - resolveFetch?.() + resolveSubmit?.() await flushPromises() - expect(fetchSpy).toHaveBeenCalled() + expect(downloadsSubmitMock).toHaveBeenCalled() expect(elements.submitButton.disabled).toBe(false) expect(elements.submitButton.textContent).toBe('Download Now') - - fetchSpy.mockRestore() }) }) }) diff --git a/src/components/Forms/Download/client/index.ts b/src/components/Forms/Download/client/index.ts index 7d634af4e..911428793 100644 --- a/src/components/Forms/Download/client/index.ts +++ b/src/components/Forms/Download/client/index.ts @@ -1,4 +1,5 @@ import { LitElement } from 'lit' +import { actions } from 'astro:actions' import { getDownloadButtonWrapper, getDownloadFormElement, @@ -114,21 +115,11 @@ export class DownloadFormElement extends LitElement { } try { - const response = await fetch('/api/downloads/submit', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }) - - if (!response.ok) { - throw new ClientScriptError({ - message: 'Failed to submit form', - }) - } + const result = await actions.downloads.submit(payload) - await response.json() + if (result.error || !result.data?.success) { + throw new ClientScriptError({ message: result.error?.message || 'Failed to submit form' }) + } this.showStatus('success', 'Thank you! Click the button below to download your resource.') diff --git a/src/components/scripts/api/__tests__/gdpr.client.spec.ts b/src/components/scripts/api/__tests__/gdpr.client.spec.ts index ae5c5f673..272e36064 100644 --- a/src/components/scripts/api/__tests__/gdpr.client.spec.ts +++ b/src/components/scripts/api/__tests__/gdpr.client.spec.ts @@ -11,7 +11,7 @@ import type { DSARRequestInput, DSARResponse, ErrorResponse, -} from '@pages/api/_contracts/gdpr.contracts' +} from '@actions/_contracts/gdpr.contracts' const fetchSpy = vi.fn() diff --git a/src/components/scripts/api/gdpr.client.ts b/src/components/scripts/api/gdpr.client.ts index 887452534..0913f2632 100644 --- a/src/components/scripts/api/gdpr.client.ts +++ b/src/components/scripts/api/gdpr.client.ts @@ -12,7 +12,7 @@ import type { DSARRequestInput, DSARResponse, ErrorResponse -} from '@pages/api/_contracts/gdpr.contracts' +} from '@actions/_contracts/gdpr.contracts' /** * Base API response type that all GDPR endpoints return diff --git a/src/env.d.ts b/src/env.d.ts index f45de6922..f2efe35d1 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1,5 +1,25 @@ /// +type RequestIdleCallbackHandle = number + +interface IdleDeadline { + didTimeout: boolean + timeRemaining(): number +} + +type IdleRequestCallback = (_deadline: IdleDeadline) => void + +interface IdleRequestOptions { + timeout?: number +} + +declare function requestIdleCallback( + _callback: IdleRequestCallback, + _options?: IdleRequestOptions, +): RequestIdleCallbackHandle + +declare function cancelIdleCallback(_handle: RequestIdleCallbackHandle): void + interface ImportMetaEnv { readonly NODE_ENV: string } diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 248f2667c..3cbb4abf0 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -107,6 +107,14 @@ const { {/* Register the service worker via @vite-pwa/astro virtual module */} + diff --git a/src/lib/api/gdpr.client.ts b/src/lib/api/gdpr.client.ts index 887452534..0913f2632 100644 --- a/src/lib/api/gdpr.client.ts +++ b/src/lib/api/gdpr.client.ts @@ -12,7 +12,7 @@ import type { DSARRequestInput, DSARResponse, ErrorResponse -} from '@pages/api/_contracts/gdpr.contracts' +} from '@actions/_contracts/gdpr.contracts' /** * Base API response type that all GDPR endpoints return diff --git a/src/lib/config/pwa.ts b/src/lib/config/pwa.ts index 6c3da24c8..5f58ce882 100644 --- a/src/lib/config/pwa.ts +++ b/src/lib/config/pwa.ts @@ -74,20 +74,36 @@ export const pwaConfig: PwaOptions = { skipWaiting: true, // fallback for navigation requests navigateFallback: '/offline', + // ensure the offline page is always available for navigation fallback + additionalManifestEntries: [{ url: '/offline', revision: null }], // caching strategy configuration runtimeCaching: [ { - urlPattern: /\.(?:html|css|js)$/, + urlPattern: /\.(?:css|js)$/, handler: 'StaleWhileRevalidate', options: { - cacheName: 'webstackbuilders-cache', + cacheName: 'webstackbuilders-assets', + cacheableResponse: { + statuses: [0, 200], + }, + expiration: { + maxEntries: 60, + maxAgeSeconds: 60 * 60 * 24 * 30, + }, }, }, { urlPattern: /\.(?:png|jpg|jpeg|gif|bmp|webp|svg|ico)$/, handler: 'CacheFirst', options: { - cacheName: 'webstackbuilders-cache', + cacheName: 'webstackbuilders-images', + cacheableResponse: { + statuses: [0, 200], + }, + expiration: { + maxEntries: 200, + maxAgeSeconds: 60 * 60 * 24 * 30, + }, }, }, ], diff --git a/src/pages/api/_logger/__tests__/index.spec.ts b/src/pages/api/_logger/__tests__/index.spec.ts deleted file mode 100644 index 57766967a..000000000 --- a/src/pages/api/_logger/__tests__/index.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Tests for the shared consent logging helper - */ -import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { recordConsent, type ConsentLogRequest } from '@pages/api/_logger' - -const fetchMock = vi.hoisted(() => vi.fn()) -vi.stubGlobal('fetch', fetchMock) - -const buildFetchResponse = (ok: boolean, payload: unknown) => ({ - ok, - json: vi.fn().mockResolvedValue(payload), -}) as unknown as Response - -const buildRequest = (): ConsentLogRequest => ({ - origin: 'https://example.com', - DataSubjectId: '1234', - email: 'user@example.com', - purposes: ['marketing'], - source: 'newsletter_form', - userAgent: 'pytest', - verified: false, -}) - -describe('recordConsent helper', () => { - beforeEach(() => { - vi.clearAllMocks() - fetchMock.mockReset() - }) - - afterAll(() => { - vi.unstubAllGlobals() - }) - - it('returns consent data when upstream succeeds', async () => { - const mockConsent = { id: 'consent-1' } - fetchMock.mockResolvedValueOnce(buildFetchResponse(true, mockConsent)) - - const response = await recordConsent(buildRequest()) - - expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/gdpr/consent', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - DataSubjectId: '1234', - email: 'user@example.com', - purposes: ['marketing'], - source: 'newsletter_form', - userAgent: 'pytest', - verified: false, - }), - }) - expect(response).toBe(mockConsent) - }) - - it('propagates upstream error codes and defaults to 502', async () => { - fetchMock.mockResolvedValueOnce(buildFetchResponse(false, { - error: { - code: 'CONSENT_FAILED', - message: 'nope', - }, - })) - - await expect(recordConsent(buildRequest())).rejects.toThrow(ApiFunctionError) - }) - - it('surfaces rate limit overruns with 429', async () => { - fetchMock.mockResolvedValueOnce(buildFetchResponse(false, { - error: { - code: 'RATE_LIMIT_EXCEEDED', - message: 'too many', - }, - })) - - await expect(recordConsent(buildRequest())).rejects.toMatchObject({ - status: 429, - code: 'RATE_LIMIT_EXCEEDED', - }) - }) -}) diff --git a/src/pages/api/_logger/index.ts b/src/pages/api/_logger/index.ts deleted file mode 100644 index bf46fe015..000000000 --- a/src/pages/api/_logger/index.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Shared consent logging helper for server-side API routes - * Provides a thin wrapper around the GDPR consent endpoint with origin handling - */ -import type { ConsentRequest, ConsentResponse, ErrorResponse } from '@pages/api/_contracts/gdpr.contracts' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' - -export type ConsentLogRequest = ConsentRequest & { - origin: string -} - -type ConsentApiResponse = { - success: true - data: ConsentResponse -} | { - success: false - error: ErrorResponse['error'] -} - -const recordConsentUpstream = async (origin: string, request: ConsentRequest): Promise => { - try { - const response = await fetch(`${origin}/api/gdpr/consent`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request), - }) - - const data = await response.json() - - if (!response.ok) { - const errorResponse = data as ErrorResponse - return { - success: false, - error: errorResponse.error, - } - } - - return { - success: true, - data: data as ConsentResponse, - } - } catch (error) { - return { - success: false, - error: { - message: error instanceof Error ? error.message : 'Network error', - code: 'INVALID_REQUEST', - }, - } - } -} - -export const recordConsent = async (request: ConsentLogRequest) => { - const { origin, ...consentRequest } = request - const response = await recordConsentUpstream(origin, consentRequest) - - if (!response.success) { - throw new ApiFunctionError({ - message: response.error.message || 'Failed to record consent.', - status: response.error.code === 'RATE_LIMIT_EXCEEDED' ? 429 : 502, - code: response.error.code, - }) - } - - return response.data -} diff --git a/src/pages/api/_utils/__tests__/rateLimit.spec.ts b/src/pages/api/_utils/__tests__/rateLimit.spec.ts deleted file mode 100644 index d03b0a1d1..000000000 --- a/src/pages/api/_utils/__tests__/rateLimit.spec.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' -import { TestError } from '@test/errors' - -type MockWindow = { - id: string - scope: string - identifier: string - hits: number - limit: number - windowMs: number - windowExpiresAt: number - updatedAt: Date -} - -type WindowState = { - current: MockWindow | undefined - resetWindow: ReturnType - incrementHits: ReturnType -} - -const { - mockIsDev, - mockIsTest, - mockIsDbError, - mockWithRateLimitWindow, -} = vi.hoisted(() => ({ - mockIsDev: vi.fn(() => false), - mockIsTest: vi.fn(() => false), - mockIsDbError: vi.fn(() => false), - mockWithRateLimitWindow: vi.fn(), -})) - -const windowStates = new Map() - -// Mock server environment helpers that get re-exported through environmentApi -vi.mock('@lib/config/environmentServer', () => ({ - isCI: vi.fn(() => false), - isDev: mockIsDev, - isE2eTest: vi.fn(() => false), - isGitHub: vi.fn(() => false), - isProd: vi.fn(() => true), - isTest: mockIsTest, - isUnitTest: vi.fn(() => false), - isVercel: vi.fn(() => false), -})) - -// Mock environment utilities BEFORE importing the module under test -vi.mock('@pages/api/_environment/environmentApi', () => ({ - isDev: mockIsDev, - isTest: mockIsTest, -})) - -vi.mock('astro:db', () => ({ - isDbError: mockIsDbError, -})) - -vi.mock('@pages/api/_utils/rateLimitStore', () => ({ - withRateLimitWindow: mockWithRateLimitWindow, -})) - -import { rateLimiters, checkRateLimit, checkContactRateLimit } from '@pages/api/_utils/rateLimit' - -function buildWindow(scope: string, identifier: string, overrides?: Partial): MockWindow { - return { - id: overrides?.id ?? `${scope}-${identifier}`, - scope, - identifier, - hits: overrides?.hits ?? 0, - limit: overrides?.limit ?? 10, - windowMs: overrides?.windowMs ?? 60_000, - windowExpiresAt: overrides?.windowExpiresAt ?? Date.now() + 60_000, - updatedAt: overrides?.updatedAt ?? new Date(), - } -} - -function createWindowState(scope: string, identifier: string, initial?: MockWindow): WindowState { - const state: WindowState = { - current: initial, - resetWindow: vi.fn(async ({ hits, limit, windowMs, windowExpiresAt }) => { - state.current = buildWindow(scope, identifier, { - hits, - limit, - windowMs, - windowExpiresAt, - }) - return state.current - }), - incrementHits: vi.fn(async () => { - if (!state.current) { - throw new TestError('Rate limit window missing') - } - state.current = { - ...state.current, - hits: state.current.hits + 1, - updatedAt: new Date(), - } - return state.current - }), - } - return state -} - -function ensureWindowState(scope: string, identifier: string): WindowState { - const key = `${scope}:${identifier}` - if (!windowStates.has(key)) { - windowStates.set(key, createWindowState(scope, identifier)) - } - return windowStates.get(key) as WindowState -} - -function seedWindow(scope: string, identifier: string, overrides?: Partial): WindowState { - const state = createWindowState(scope, identifier, buildWindow(scope, identifier, overrides)) - windowStates.set(`${scope}:${identifier}`, state) - return state -} - -describe('Rate Limit Utils', () => { - beforeEach(() => { - vi.clearAllMocks() - windowStates.clear() - mockIsDev.mockReturnValue(false) - mockIsTest.mockReturnValue(false) - mockIsDbError.mockReturnValue(false) - mockWithRateLimitWindow.mockImplementation(async (scope, identifier, handler) => { - const state = ensureWindowState(scope as string, identifier) - return handler({ - window: state.current, - resetWindow: state.resetWindow, - incrementHits: state.incrementHits, - }) - }) - }) - - describe('rateLimiters configuration', () => { - it('should export consent rate limiter', () => { - expect(rateLimiters.consent).toBeDefined() - expect(typeof rateLimiters.consent.limit).toBe('function') - }) - - it('should export consentRead rate limiter', () => { - expect(rateLimiters.consentRead).toBeDefined() - expect(typeof rateLimiters.consentRead.limit).toBe('function') - }) - - it('should export export rate limiter', () => { - expect(rateLimiters.export).toBeDefined() - expect(typeof rateLimiters.export.limit).toBe('function') - }) - - it('should export delete rate limiter', () => { - expect(rateLimiters.delete).toBeDefined() - expect(typeof rateLimiters.delete.limit).toBe('function') - }) - - it('should export contact rate limiter', () => { - expect(rateLimiters.contact).toBeDefined() - expect(typeof rateLimiters.contact.limit).toBe('function') - }) - }) - - describe('checkRateLimit', () => { - it('should return success when rate limit is not exceeded', async () => { - const result = await checkRateLimit(rateLimiters.consent, '192.168.1.1') - - const state = ensureWindowState('consent', '192.168.1.1') - expect(state.resetWindow).toHaveBeenCalledTimes(1) - expect(result.success).toBe(true) - expect(typeof result.reset).toBe('number') - }) - - it('should return failure when rate limit is exceeded', async () => { - seedWindow('consent', '192.168.1.1', { - hits: 10, - limit: 10, - windowExpiresAt: Date.now() + 60000, - }) - const result = await checkRateLimit(rateLimiters.consent, '192.168.1.1') - - const state = ensureWindowState('consent', '192.168.1.1') - expect(state.incrementHits).not.toHaveBeenCalled() - expect(result.success).toBe(false) - expect(typeof result.reset).toBe('number') - }) - - it('should handle different identifiers', async () => { - await checkRateLimit(rateLimiters.export, 'user@example.com') - expect(mockWithRateLimitWindow).toHaveBeenCalledWith('export', 'user@example.com', expect.any(Function)) - - await checkRateLimit(rateLimiters.export, '10.0.0.1') - expect(mockWithRateLimitWindow).toHaveBeenCalledWith('export', '10.0.0.1', expect.any(Function)) - }) - - it('should propagate rate limiter errors', async () => { - const error = new TestError('DB offline') - mockWithRateLimitWindow.mockRejectedValueOnce(error) - - await expect( - checkRateLimit(rateLimiters.consent, '192.168.1.1') - ).rejects.toThrow('DB offline') - }) - - it('should work with different rate limiter configurations', async () => { - seedWindow('consent', 'test-ip', { - hits: 0, - limit: 10, - windowExpiresAt: Date.now() + 60000, - }) - let result = await checkRateLimit(rateLimiters.consent, 'test-ip') - expect(result.success).toBe(true) - - seedWindow('consentRead', 'test-ip', { - hits: 0, - limit: 30, - windowExpiresAt: Date.now() + 60000, - }) - result = await checkRateLimit(rateLimiters.consentRead, 'test-ip') - expect(result.success).toBe(true) - - seedWindow('export', 'test-ip', { - hits: 5, - limit: 5, - windowExpiresAt: Date.now() + 60000, - }) - result = await checkRateLimit(rateLimiters.export, 'test-ip') - expect(result.success).toBe(false) - }) - - it('should return fallback response on database errors', async () => { - mockIsDbError.mockReturnValue(true) - mockWithRateLimitWindow.mockRejectedValueOnce(new Error('db failure')) - - const result = await checkRateLimit(rateLimiters.consent, '192.168.1.1') - - expect(result.success).toBe(false) - expect(typeof result.reset).toBe('number') - }) - }) - - describe('checkContactRateLimit', () => { - let isDev: ReturnType - let isTest: ReturnType - - beforeEach(() => { - isDev = mockIsDev - isTest = mockIsTest - isDev.mockReturnValue(false) - isTest.mockReturnValue(false) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('should return true when isTest() returns true', () => { - isTest.mockReturnValue(true) - - const result = checkContactRateLimit('192.168.1.1') - expect(result).toBe(true) - }) - - it('should return true when isDev() returns true', () => { - isDev.mockReturnValue(true) - - const result = checkContactRateLimit('192.168.1.1') - expect(result).toBe(true) - }) - - it('should allow requests under the limit in production', () => { - // isDev() and isTest() return false (set in beforeEach) - - const ip = '192.168.1.2' - - // First 5 requests should succeed - for (let i = 0; i < 5; i++) { - const result = checkContactRateLimit(ip) - expect(result).toBe(true) - } - }) - - it('should block requests over the limit in production', () => { - // isDev() and isTest() return false (set in beforeEach) - - const ip = '192.168.1.3' - - // Use up the limit (5 requests) - for (let i = 0; i < 5; i++) { - checkContactRateLimit(ip) - } - - // 6th request should be blocked - const result = checkContactRateLimit(ip) - expect(result).toBe(false) - }) - - it('should isolate rate limits by IP address', () => { - // isDev() and isTest() return false (set in beforeEach) - - const ip1 = '192.168.1.4' - const ip2 = '192.168.1.5' - - // Use up limit for first IP - for (let i = 0; i < 5; i++) { - checkContactRateLimit(ip1) - } - - // First IP should be blocked - expect(checkContactRateLimit(ip1)).toBe(false) - - // Second IP should still work - expect(checkContactRateLimit(ip2)).toBe(true) - }) - }) -}) \ No newline at end of file diff --git a/src/pages/api/_environment/environmentApi.ts b/src/pages/api/_utils/environment/environmentApi.ts similarity index 59% rename from src/pages/api/_environment/environmentApi.ts rename to src/pages/api/_utils/environment/environmentApi.ts index ac2eedacd..d12e58530 100644 --- a/src/pages/api/_environment/environmentApi.ts +++ b/src/pages/api/_utils/environment/environmentApi.ts @@ -5,7 +5,7 @@ * routes to import it. Vercel exposes environment variables in Vercel serverless * functions with process.env. */ -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' +import { ApiFunctionError } from '../errors/ApiFunctionError' import { isUnitTest } from '@lib/config/environmentServer' export { isCI, @@ -31,23 +31,20 @@ export const isProd = () => { } /** - * Privacy Policy Version Utility - * - * Provides access to the privacy policy version that's injected at build time - * via the PrivacyPolicyVersion Astro integration. + * Gets the Vercel cron secret. This value is set in Vercel env vars and + * made available to serverless functions by default. The purpose is to + * prevent abuse of malicious parties calling cron jobs for the project. * - * @see src/integrations/PrivacyPolicyVersion/index.ts - * @returns The privacy policy version in YYYY-MM-DD format - * @throws {ApiFunctionError} If PRIVACY_POLICY_VERSION is not set + * @throws {ApiFunctionError} If CRON_SECRET is not set */ -export function getPrivacyPolicyVersion(): string { - const version = import.meta.env['PRIVACY_POLICY_VERSION'] - if (!version) { +export function getCronSecret(): string { + const secret = process.env['CRON_SECRET'] + if (!secret) { throw new ApiFunctionError( - 'PRIVACY_POLICY_VERSION environment variable is not set. This should be injected by the PrivacyPolicyVersion integration.' + 'CRON_SECRET environment variable is not set. This is either set in a .env file locally during development, in GitHub Secrets and made available in CI runs by the .github/workflows actions, or by Vercel as an env var made available to serverless functions in deployment.' ) } - return version + return secret } /** @@ -73,56 +70,6 @@ export function getPackageRelease(): string { return release } -/** - * Gets the Convertkit API Key. This value is set in Vercel env vars and - * made available to serverless functions by default. The purpose is to - * prevent abuse of malicious parties calling cron jobs for the project. - * - * @throws {ApiFunctionError} If CONVERTKIT_API_KEY is not set - */ -export function getConvertkitApiKey(): string { - const secret = process.env['CONVERTKIT_API_KEY'] - if (!secret) { - throw new ApiFunctionError( - 'CONVERTKIT_API_KEY environment variable is not set. This is either set in a .env file locally during development, in GitHub Secrets and made available in CI runs by the .github/workflows actions, or by Vercel as an env var made available to serverless functions in deployment.' - ) - } - return secret -} - -/** - * Gets the Vercel cron secret. This value is set in Vercel env vars and - * made available to serverless functions by default. The purpose is to - * prevent abuse of malicious parties calling cron jobs for the project. - * - * @throws {ApiFunctionError} If CRON_SECRET is not set - */ -export function getCronSecret(): string { - const secret = process.env['CRON_SECRET'] - if (!secret) { - throw new ApiFunctionError( - 'CRON_SECRET environment variable is not set. This is either set in a .env file locally during development, in GitHub Secrets and made available in CI runs by the .github/workflows actions, or by Vercel as an env var made available to serverless functions in deployment.' - ) - } - return secret -} - -/** - * Gets the Resend API key. This value is set in Vercel env vars and - * made available to serverless functions by default. - * - * @throws {ApiFunctionError} If RESEND_API_KEY is not set - */ -export function getResendApiKey(): string { - const key = process.env['RESEND_API_KEY'] - if (!key) { - throw new ApiFunctionError( - 'RESEND_API_KEY environment variable is not set. This is either set in a .env file locally during development, in GitHub Secrets and made available in CI runs by the .github/workflows actions, or by Vercel as an env var made available to serverless functions in deployment.' - ) - } - return key -} - /** * Gets the Sentry DSN. This value is set in Vercel env vars and * made available to serverless functions by default. diff --git a/src/pages/api/_utils/environment/index.ts b/src/pages/api/_utils/environment/index.ts new file mode 100644 index 000000000..f798a67ca --- /dev/null +++ b/src/pages/api/_utils/environment/index.ts @@ -0,0 +1,9 @@ +export { + getCronSecret, + getPackageRelease, + getSentryDsn, + isDev, + isProd, + isTest, +} from './environmentApi' +export { getSiteUrl } from './siteUrlApi' diff --git a/src/pages/api/_environment/siteUrlApi.ts b/src/pages/api/_utils/environment/siteUrlApi.ts similarity index 87% rename from src/pages/api/_environment/siteUrlApi.ts rename to src/pages/api/_utils/environment/siteUrlApi.ts index 1c146d9ab..daba5c732 100644 --- a/src/pages/api/_environment/siteUrlApi.ts +++ b/src/pages/api/_utils/environment/siteUrlApi.ts @@ -2,7 +2,7 @@ * Server-side method to determine correct URL */ -import packageJson from '../../../../package.json' with { type: 'json' } +import packageJson from '../../../../../package.json' with { type: 'json' } import { isVercel } from './environmentApi' const devServerPort = process.env['DEV_SERVER_PORT']?.trim() diff --git a/src/pages/api/_errors/ApiFunctionError.ts b/src/pages/api/_utils/errors/ApiFunctionError.ts similarity index 100% rename from src/pages/api/_errors/ApiFunctionError.ts rename to src/pages/api/_utils/errors/ApiFunctionError.ts diff --git a/src/pages/api/_errors/__tests__/ApiFunctionError.spec.ts b/src/pages/api/_utils/errors/__tests__/ApiFunctionError.spec.ts similarity index 98% rename from src/pages/api/_errors/__tests__/ApiFunctionError.spec.ts rename to src/pages/api/_utils/errors/__tests__/ApiFunctionError.spec.ts index d63410791..344fe060f 100644 --- a/src/pages/api/_errors/__tests__/ApiFunctionError.spec.ts +++ b/src/pages/api/_utils/errors/__tests__/ApiFunctionError.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest' import { TestError } from '@test/errors' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' +import { ApiFunctionError } from '@pages/api/_utils/errors' describe(`ApiFunctionError basics`, () => { test(`captures stack, name, and custom metadata`, () => { diff --git a/src/pages/api/_errors/__tests__/apiFunctionHandler.spec.ts b/src/pages/api/_utils/errors/__tests__/apiFunctionHandler.spec.ts similarity index 93% rename from src/pages/api/_errors/__tests__/apiFunctionHandler.spec.ts rename to src/pages/api/_utils/errors/__tests__/apiFunctionHandler.spec.ts index fe3af1c20..62437b554 100644 --- a/src/pages/api/_errors/__tests__/apiFunctionHandler.spec.ts +++ b/src/pages/api/_utils/errors/__tests__/apiFunctionHandler.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from 'vitest' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' import { + ApiFunctionError, buildApiErrorResponse, formatApiErrorLogEntry, -} from '@pages/api/_errors/apiFunctionHandler' +} from '@pages/api/_utils/errors' describe(`formatApiErrorLogEntry`, () => { test(`returns structured log payload with metadata`, () => { diff --git a/src/pages/api/_errors/apiFunctionHandler.ts b/src/pages/api/_utils/errors/apiFunctionHandler.ts similarity index 98% rename from src/pages/api/_errors/apiFunctionHandler.ts rename to src/pages/api/_utils/errors/apiFunctionHandler.ts index 52debaac7..1583516f2 100644 --- a/src/pages/api/_errors/apiFunctionHandler.ts +++ b/src/pages/api/_utils/errors/apiFunctionHandler.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto' import { captureException, withScope } from '@sentry/astro' -import { ensureApiSentry } from '@pages/api/_sentry' -import { isDev, isProd, isTest } from '@pages/api/_environment/environmentApi' +import { ensureApiSentry } from '@pages/api/_utils/sentry' +import { isDev, isProd, isTest } from '@pages/api/_utils/environment' import { ApiFunctionError, type ApiFunctionErrorParams } from './ApiFunctionError' ensureApiSentry() diff --git a/src/pages/api/_utils/errors/index.ts b/src/pages/api/_utils/errors/index.ts new file mode 100644 index 000000000..18dbdfe84 --- /dev/null +++ b/src/pages/api/_utils/errors/index.ts @@ -0,0 +1,9 @@ +export { ApiFunctionError } from './ApiFunctionError' +export { + type ApiFunctionContext, + type ApiFunctionConsent, + type ApiRequestMetadata, + formatApiErrorLogEntry, + buildApiErrorResponse, + handleApiFunctionError +} from './apiFunctionHandler' diff --git a/src/pages/api/_utils/index.ts b/src/pages/api/_utils/index.ts deleted file mode 100644 index 886d74da1..000000000 --- a/src/pages/api/_utils/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - rateLimiters, - checkRateLimit, - checkContactRateLimit, -} from './rateLimit' diff --git a/src/pages/api/_utils/requestContext.ts b/src/pages/api/_utils/requestContext.ts index be2939fe1..1ea9f3e49 100644 --- a/src/pages/api/_utils/requestContext.ts +++ b/src/pages/api/_utils/requestContext.ts @@ -4,7 +4,7 @@ import type { ApiFunctionContext, ApiFunctionConsent, ApiRequestMetadata, -} from '@pages/api/_errors/apiFunctionHandler' +} from '@pages/api/_utils/errors' export interface ApiFunctionContextOptions { route: string diff --git a/src/pages/api/_sentry/__tests__/index.spec.ts b/src/pages/api/_utils/sentry/__tests__/index.spec.ts similarity index 88% rename from src/pages/api/_sentry/__tests__/index.spec.ts rename to src/pages/api/_utils/sentry/__tests__/index.spec.ts index 8f7fe9b62..1728a1e3a 100644 --- a/src/pages/api/_sentry/__tests__/index.spec.ts +++ b/src/pages/api/_utils/sentry/__tests__/index.spec.ts @@ -15,7 +15,7 @@ vi.mock('@sentry/astro', () => ({ init: sentryInitMock, })) -vi.mock('@pages/api/_environment/environmentApi', () => envMocks) +vi.mock('@pages/api/_utils/environment', () => envMocks) describe('ensureApiSentry', () => { beforeEach(() => { @@ -30,7 +30,7 @@ describe('ensureApiSentry', () => { it('skips initialization outside production', async () => { envMocks.isProd.mockReturnValue(false) - const module = await import('@pages/api/_sentry') + const module = await import('@pages/api/_utils/sentry') module.ensureApiSentry() expect(sentryInitMock).not.toHaveBeenCalled() @@ -40,7 +40,7 @@ describe('ensureApiSentry', () => { envMocks.isProd.mockReturnValue(true) envMocks.isDev.mockReturnValue(false) - const module = await import('@pages/api/_sentry') + const module = await import('@pages/api/_utils/sentry') expect(sentryInitMock).toHaveBeenCalledTimes(1) const initConfig = sentryInitMock.mock.calls[0]![0] @@ -58,7 +58,7 @@ describe('ensureApiSentry', () => { envMocks.isProd.mockReturnValue(true) envMocks.isDev.mockReturnValue(true) - const module = await import('@pages/api/_sentry') + const module = await import('@pages/api/_utils/sentry') const config = sentryInitMock.mock.calls[0]![0] const event = {} diff --git a/src/pages/api/_sentry/index.ts b/src/pages/api/_utils/sentry/index.ts similarity index 84% rename from src/pages/api/_sentry/index.ts rename to src/pages/api/_utils/sentry/index.ts index 1980fed75..b72dabd65 100644 --- a/src/pages/api/_sentry/index.ts +++ b/src/pages/api/_utils/sentry/index.ts @@ -1,5 +1,10 @@ import { init as sentryInit } from '@sentry/astro' -import { getPackageRelease, getSentryDsn, isDev, isProd } from '@pages/api/_environment/environmentApi' +import { + getPackageRelease, + getSentryDsn, + isDev, + isProd +} from '@pages/api/_utils/environment' let initialized = false diff --git a/src/pages/api/contact/__tests__/index.spec.ts b/src/pages/api/contact/__tests__/index.spec.ts deleted file mode 100644 index 6e65f0171..000000000 --- a/src/pages/api/contact/__tests__/index.spec.ts +++ /dev/null @@ -1,435 +0,0 @@ -/** - * Unit tests for contact form API endpoint - */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { POST, OPTIONS } from '@pages/api/contact/index' - -vi.mock('astro:db', () => ({ - isDbError: () => false, -})) - -// Mock Resend before importing the module -const mockSend = vi.fn().mockResolvedValue({ data: { id: 'test-email-id' } }) - -vi.mock('resend', () => { - return { - Resend: class MockResend { - emails = { - send: mockSend, - } - }, - } -}) - -// Mock fetch for GDPR consent API calls -const mockFetch = vi.fn() -global.fetch = mockFetch - -describe('Contact API - POST /api/contact', () => { - beforeEach(() => { - vi.clearAllMocks() - mockSend.mockResolvedValue({ data: { id: 'test-email-id' } }) - - // Mock successful GDPR consent API response - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - success: true, - record: { - id: 'test-consent-id', - DataSubjectId: 'test-uuid-123', - email: 'test@example.com', - purposes: ['contact'], - timestamp: new Date().toISOString(), - source: 'contact_form', - userAgent: 'Test Browser', - privacyPolicyVersion: '2025-11-09', - verified: true, - } - }) - }) - - // Set mock env vars - vi.stubEnv('RESEND_API_KEY', 'test-api-key') - vi.stubEnv('NODE_ENV', 'test') - }) - - afterEach(() => { - vi.clearAllMocks() - vi.unstubAllEnvs() - }) - - it('should accept valid contact form submission', async () => { - const request = new Request('http://localhost/api/contact', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-forwarded-for': '192.168.1.1', - 'user-agent': 'Test Browser', - }, - body: JSON.stringify({ - name: 'John Doe', - email: 'john@example.com', - message: 'This is a test message with sufficient length', - consent: true, - }), - }) - - const response = await POST({ request } as any) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.message).toContain('Thank you') - }) - - it('should reject submission without name', async () => { - const request = new Request('http://localhost/api/contact', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-forwarded-for': '10.0.0.2', // Unique IP to avoid rate limiting - }, - body: JSON.stringify({ - email: 'test@example.com', - message: 'Test message with enough content', - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('Name is required') - }) - - it('should reject submission with short name', async () => { - const request = new Request('http://localhost/api/contact', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-forwarded-for': '10.0.0.3', // Unique IP to avoid rate limiting - }, - body: JSON.stringify({ - name: 'A', - email: 'test@example.com', - message: 'Test message with enough content', - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('at least 2 characters') - }) - - it('should reject submission without email', async () => { - const request = new Request('http://localhost/api/contact', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-forwarded-for': '10.0.0.4', // Unique IP to avoid rate limiting - }, - body: JSON.stringify({ - name: 'John Doe', - message: 'Test message with enough content', - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('Email is required') - }) - - it('should reject submission with invalid email', async () => { - const request = new Request('http://localhost/api/contact', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-forwarded-for': '10.0.0.5', // Unique IP to avoid rate limiting - }, - body: JSON.stringify({ - name: 'John Doe', - email: 'invalid-email', - message: 'Test message with enough content', - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('Invalid email') - }) - - it('should reject submission without message', async () => { - const request = new Request('http://localhost/api/contact', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-forwarded-for': '10.0.0.6', // Unique IP to avoid rate limiting - }, - body: JSON.stringify({ - name: 'John Doe', - email: 'test@example.com', - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('Message is required') - }) - - it('should reject submission with short message', async () => { - const request = new Request('http://localhost/api/contact', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-forwarded-for': '10.0.0.7', // Unique IP to avoid rate limiting - }, - body: JSON.stringify({ - name: 'John Doe', - email: 'test@example.com', - message: 'Too short', - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('at least 10 characters') - }) - - it('should reject submission with spam content', async () => { - const request = new Request('http://localhost/api/contact', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-forwarded-for': '10.0.0.8', // Unique IP to avoid rate limiting - }, - body: JSON.stringify({ - name: 'John Doe', - email: 'test@example.com', - message: 'Click here to win the casino lottery with viagra', - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('spam') - }) - - it('should bypass rate limiting in test environment', async () => { - // In test/dev/CI environments, rate limiting is disabled - // This test verifies that we can make unlimited requests - const ip = '192.168.1.unique-for-ratelimit-test' - const headers = { - 'Content-Type': 'application/json', - 'x-forwarded-for': ip, - } - - // Make 10 requests - normally limited to 5 per 15 minutes - // All should succeed because rate limiting is bypassed - for (let i = 0; i < 10; i++) { - const request = new Request('http://localhost/api/contact', { - method: 'POST', - headers, - body: JSON.stringify({ - name: 'John Doe', - email: `test${i}@example.com`, - message: `Test message number ${i} with sufficient length`, - }), - }) - const response = await POST({ request } as any) - expect(response.status).toBe(200) - } - }) - - it('should handle optional fields correctly', async () => { - const request = new Request('http://localhost/api/contact', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-forwarded-for': '10.0.0.9', // Unique IP to avoid rate limiting - }, - body: JSON.stringify({ - name: 'John Doe', - email: 'test@example.com', - message: 'Test message with enough content', - phone: '555-1234', - service: 'Web Development', - budget: '$10k-$50k', - timeline: '3 months', - website: 'https://example.com', - }), - }) - - const response = await POST({ request } as any) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - }) - - it('should record consent when provided', async () => { - const request = new Request('http://localhost/api/contact', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-forwarded-for': '192.168.1.1', - 'user-agent': 'Test Browser', - }, - body: JSON.stringify({ - name: 'John Doe', - email: 'test@example.com', - message: 'Test message with enough content', - consent: true, - }), - }) - - await POST({ request } as any) - - expect(mockFetch).toHaveBeenCalledWith( - 'http://localhost/api/gdpr/consent', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'Content-Type': 'application/json', - }), - body: expect.stringContaining('"email":"test@example.com"'), - }) - ) - }) - - it('should not record consent when not provided', async () => { - const request = new Request('http://localhost/api/contact', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-forwarded-for': '10.0.0.10', // Unique IP to avoid rate limiting - }, - body: JSON.stringify({ - name: 'John Doe', - email: 'test@example.com', - message: 'Test message with enough content', - }), - }) - - await POST({ request } as any) - - expect(mockFetch).not.toHaveBeenCalledWith( - expect.stringMatching(/\/api\/gdpr\/consent$/), - expect.any(Object) - ) - }) - - it('should continue form submission even if consent logging fails', async () => { - // Mock GDPR consent API failure - mockFetch.mockResolvedValueOnce({ - ok: false, - json: async () => ({ success: false, error: { code: 'SERVER_ERROR', message: 'Database error' } }) - }) - - const request = new Request('http://localhost/api/contact', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-forwarded-for': '10.0.0.11', // Unique IP to avoid rate limiting - }, - body: JSON.stringify({ - name: 'John Doe', - email: 'test@example.com', - message: 'Test message with enough content', - consent: true, - }), - }) - - const response = await POST({ request } as any) - const data = await response.json() - - // Form submission should still succeed - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.message).toContain('Thank you') - - // Consent API should have been called but failed - expect(mockFetch).toHaveBeenCalledWith( - 'http://localhost/api/gdpr/consent', - expect.any(Object) - ) - }) - - it('should generate DataSubjectId when not provided', async () => { - const request = new Request('http://localhost/api/contact', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-forwarded-for': '10.0.0.12', - }, - body: JSON.stringify({ - name: 'John Doe', - email: 'test@example.com', - message: 'Test message with enough content', - consent: true, - }), - }) - - await POST({ request } as any) - - expect(mockFetch).toHaveBeenCalledWith( - 'http://localhost/api/gdpr/consent', - expect.objectContaining({ - body: expect.stringMatching(/"DataSubjectId":"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"/), - }) - ) - }) - - it('should validate provided DataSubjectId', async () => { - const request = new Request('http://localhost/api/contact', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-forwarded-for': '10.0.0.13', - }, - body: JSON.stringify({ - name: 'John Doe', - email: 'test@example.com', - message: 'Test message with enough content', - consent: true, - DataSubjectId: 'invalid-uuid', - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('Invalid DataSubjectId format') - }) -}) - -describe('Contact API - OPTIONS /api/contact', () => { - it('should return CORS headers', async () => { - const response = await OPTIONS({} as any) - - expect(response.status).toBe(200) - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*') - expect(response.headers.get('Access-Control-Allow-Methods')).toContain('POST') - expect(response.headers.get('Access-Control-Allow-Headers')).toContain('Content-Type') - }) -}) diff --git a/src/pages/api/contact/index.ts b/src/pages/api/contact/index.ts deleted file mode 100644 index 73e79e359..000000000 --- a/src/pages/api/contact/index.ts +++ /dev/null @@ -1,450 +0,0 @@ -/** - * Astro API endpoint for contact form submission - * Implements file upload support with Resend email delivery - * - * With Vercel adapter, this becomes a serverless function automatically - */ -import type { APIRoute } from 'astro' -import { Resend } from 'resend' -import emailValidator from 'email-validator' -import { v4 as uuidv4, validate as uuidValidate } from 'uuid' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' -import { getResendApiKey, isDev, isTest } from '@pages/api/_environment/environmentApi' -import { checkContactRateLimit } from '@pages/api/_utils/rateLimit' -import { createApiFunctionContext, createRateLimitIdentifier } from '@pages/api/_utils/requestContext' - -export const prerender = false // Force SSR for this endpoint - -// Types -interface ContactFormData { - name: string - email: string - phone?: string - message: string - consent?: boolean - DataSubjectId?: string // Optional - will be generated if not provided - service?: string - budget?: string - timeline?: string - website?: string -} - -interface FileAttachment { - filename: string - content: Buffer - contentType: string - size: number -} - -interface EmailData { - from: string - to: string - subject: string - html: string -} - -/** - * Validate contact form input - */ -function validateInput(body: ContactFormData): string[] { - const errors: string[] = [] - - // Name validation - if (!body.name?.trim()) { - errors.push('Name is required') - } else if (body.name.length < 2) { - errors.push('Name must be at least 2 characters') - } else if (body.name.length > 100) { - errors.push('Name must be less than 100 characters') - } - - // Email validation - if (!body.email?.trim()) { - errors.push('Email is required') - } else { - if (!emailValidator.validate(body.email.trim())) { - errors.push('Invalid email address') - } - } - - // Message validation - if (!body.message?.trim()) { - errors.push('Message is required') - } else if (body.message.length < 10) { - errors.push('Message must be at least 10 characters') - } else if (body.message.length > 2000) { - errors.push('Message must be less than 2000 characters') - } - - // Check for spam patterns - const spamPatterns = ['viagra', 'cialis', 'casino', 'poker', 'lottery'] - const messageContent = `${body.name} ${body.email} ${body.message}`.toLowerCase() - if (spamPatterns.some((pattern) => messageContent.includes(pattern))) { - errors.push('Message appears to contain spam') - } - - return errors -} - -/** - * Generate HTML email content - */ -function generateEmailContent(data: ContactFormData, files: FileAttachment[]): string { - const fields = [ - `

Name: ${escapeHtml(data.name)}

`, - `

Email: ${escapeHtml(data.email)}

`, - ] - - if (data.phone) { - fields.push(`

Phone: ${escapeHtml(data.phone)}

`) - } - if (data.service) { - fields.push(`

Service: ${escapeHtml(data.service)}

`) - } - if (data.budget) { - fields.push(`

Budget: ${escapeHtml(data.budget)}

`) - } - if (data.timeline) { - fields.push(`

Timeline: ${escapeHtml(data.timeline)}

`) - } - if (data.website) { - fields.push(`

Website: ${escapeHtml(data.website)}

`) - } - - fields.push(`

Message:

`) - fields.push(`

${escapeHtml(data.message).replace(/\n/g, '
')}

`) - - if (files.length > 0) { - fields.push(`

Attachments:

`) - fields.push('
    ') - files.forEach((file) => { - fields.push(`
  • ${escapeHtml(file.filename)} (${formatFileSize(file.size)})
  • `) - }) - fields.push('
') - } - - fields.push(`

Consent Given: ${data.consent ? 'Yes' : 'No'}

`) - - return ` - - - - - - - -

New Contact Form Submission

-${fields.join('\n')} - - -` -} - -/** - * Escape HTML special characters - */ -function escapeHtml(text: string): string { - const map: Record = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - } - return text.replace(/[&<>"']/g, (char) => map[char] || char) -} - -/** - * Format file size for display - */ -function formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB` - return `${(bytes / (1024 * 1024)).toFixed(2)} MB` -} - -/** - * Send email via Resend - */ -async function sendEmail( - emailData: EmailData, - files: FileAttachment[] -): Promise { - if (isTest() || isDev()) { - return - } - - const resendPayload = { - from: emailData.from, - to: emailData.to, - subject: emailData.subject, - html: emailData.html, - ...(files.length > 0 && { - attachments: files.map((file) => ({ - filename: file.filename, - content: file.content, - })), - }), - } - - const handleSendError = (error: unknown) => { - console.error('[contact] Resend delivery error:', error) - throw new ApiFunctionError({ - message: 'Failed to send email. Please try again later.', - cause: error, - code: 'RESEND_SEND_FAILED', - status: 502, - route: '/api/contact', - operation: 'sendEmail' - }) - } - - const resend = new Resend(getResendApiKey()) - - try { - // Prepare attachments for Resend - const attachments = files.map((file) => ({ - filename: file.filename, - content: file.content, - })) - - const response = await resend.emails.send({ - ...resendPayload, - ...(attachments.length > 0 && { attachments }), - }) - - if (!response.data) { - throw new Error(response.error?.message || 'Failed to send email') - } - } catch (error) { - handleSendError(error) - } -} - -/** - * Main API handler for contact form submissions - */ -export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { - const { context: apiContext, fingerprint } = createApiFunctionContext({ - route: '/api/contact', - operation: 'POST', - request, - cookies, - clientAddress, - }) - - const userAgent = request.headers.get('user-agent') || 'unknown' - const ip = - clientAddress || - request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || - request.headers.get('x-real-ip') || - 'unknown' - - try { - const rateLimitIdentifier = createRateLimitIdentifier('contact', fingerprint) - if (!checkContactRateLimit(rateLimitIdentifier)) { - throw new ApiFunctionError({ - message: 'Too many form submissions. Please try again later.', - status: 429, - code: 'RATE_LIMIT_EXCEEDED', - }) - } - - const contentType = request.headers.get('content-type') || '' - let formData: ContactFormData - const files: FileAttachment[] = [] - - if (contentType.includes('multipart/form-data')) { - // Handle file uploads - const form = await request.formData() - formData = { - name: form.get('name') as string, - email: form.get('email') as string, - message: form.get('message') as string, - consent: form.get('consent') === 'true', - } - - // Add optional fields if present - const phone = form.get('phone') as string - const service = form.get('service') as string - const budget = form.get('budget') as string - const timeline = form.get('timeline') as string - const website = form.get('website') as string - - if (phone) formData.phone = phone - if (service) formData.service = service - if (budget) formData.budget = budget - if (timeline) formData.timeline = timeline - if (website) formData.website = website - - // Process file attachments - const allowedTypes = [ - 'image/jpeg', - 'image/png', - 'image/gif', - 'application/pdf', - 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - ] - const maxFileSize = 10 * 1024 * 1024 // 10MB - const maxFiles = 5 - - let fileCount = 0 - for (const [key, value] of form as unknown as Iterable<[ - string, - FormDataEntryValue, - ]>) { - if (key.startsWith('file') && value instanceof File && value.size > 0) { - fileCount++ - - if (fileCount > maxFiles) { - throw new ApiFunctionError({ - message: `Maximum ${maxFiles} files allowed`, - status: 400, - code: 'FILE_COUNT_EXCEEDED', - details: { maxFiles }, - }) - } - - if (value.size > maxFileSize) { - throw new ApiFunctionError({ - message: `File ${value.name} exceeds 10MB limit`, - status: 400, - code: 'FILE_TOO_LARGE', - details: { file: value.name, maxBytes: maxFileSize }, - }) - } - - if (!allowedTypes.includes(value.type)) { - throw new ApiFunctionError({ - message: `File type ${value.type} not allowed`, - status: 400, - code: 'FILE_TYPE_NOT_ALLOWED', - details: { file: value.name, type: value.type }, - }) - } - - const buffer = Buffer.from(await value.arrayBuffer()) - files.push({ - filename: value.name, - content: buffer, - contentType: value.type, - size: value.size, - }) - } - } - } else { - try { - formData = (await request.json()) as ContactFormData - } catch { - throw new ApiFunctionError({ - message: 'Invalid JSON payload', - status: 400, - code: 'INVALID_JSON', - }) - } - } - - const validationErrors = validateInput(formData) - if (validationErrors.length > 0) { - throw new ApiFunctionError({ - message: validationErrors[0], - status: 400, - code: 'INVALID_REQUEST', - details: { errors: validationErrors }, - }) - } - - if (formData.consent) { - let subjectId = formData.DataSubjectId - if (!subjectId) { - subjectId = uuidv4() - } else if (!uuidValidate(subjectId)) { - throw new ApiFunctionError({ - message: 'Invalid DataSubjectId format', - status: 400, - code: 'INVALID_UUID', - }) - } - - const consentPayload = { - DataSubjectId: subjectId, - email: formData.email, - purposes: ['contact'], - source: 'contact_form', - userAgent, - ...(ip !== 'unknown' && { ipAddress: ip }), - verified: true, - } - - try { - const consentResponse = await fetch(`${new URL(request.url).origin}/api/gdpr/consent`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(consentPayload), - }) - - if (!consentResponse.ok) { - throw new ApiFunctionError({ - message: 'Failed to record consent. Please try again later.', - status: 502, - code: 'CONSENT_RECORD_FAILED', - details: { consentPayload }, - }) - } - } catch (consentError) { - handleApiFunctionError(consentError, { - ...apiContext, - operation: 'POST:consent', - status: 502, - code: 'CONSENT_RECORD_FAILED', - }) - } - } - - const htmlContent = generateEmailContent(formData, files) - - const emailData: EmailData = { - from: 'contact@webstackbuilders.com', - to: 'info@webstackbuilders.com', - subject: `Contact Form: ${formData.name}`, - html: htmlContent, - } - - await sendEmail(emailData, files) - - return new Response( - JSON.stringify({ - success: true, - message: 'Thank you for your message. We will get back to you soon!', - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }, - ) - } catch (error) { - const serverError = handleApiFunctionError(error, apiContext) - - return buildApiErrorResponse(serverError, { - fallbackMessage: 'An unexpected error occurred. Please try again.', - }) - } -} - -// Handle OPTIONS for CORS -export const OPTIONS: APIRoute = async () => { - return new Response(null, { - status: 200, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - }, - }) -} diff --git a/src/pages/api/cron/__tests__/cleanup.spec.ts b/src/pages/api/cron/__tests__/cleanup.spec.ts index 6fbcf9ca3..901fc10d0 100644 --- a/src/pages/api/cron/__tests__/cleanup.spec.ts +++ b/src/pages/api/cron/__tests__/cleanup.spec.ts @@ -21,9 +21,9 @@ vi.mock('astro:db', () => ({ and: vi.fn((...args) => ({ op: 'and', args })), })) -vi.mock('@pages/api/_environment/environmentApi', async () => { - const actual = await vi.importActual( - '@pages/api/_environment/environmentApi', +vi.mock('@pages/api/_utils/environment', async () => { + const actual = await vi.importActual( + '@pages/api/_utils/environment', ) return { ...actual, diff --git a/src/pages/api/cron/__tests__/runner.spec.ts b/src/pages/api/cron/__tests__/runner.spec.ts index c99e1f8a4..7fb216d97 100644 --- a/src/pages/api/cron/__tests__/runner.spec.ts +++ b/src/pages/api/cron/__tests__/runner.spec.ts @@ -5,9 +5,9 @@ import { GET as runAll } from '@pages/api/cron/run-all' const getCronSecretMock = vi.hoisted(() => vi.fn(() => 'cron-secret')) const getSiteUrlMock = vi.hoisted(() => vi.fn(() => 'https://example.com')) -vi.mock('@pages/api/_environment/environmentApi', async () => { - const actual = await vi.importActual( - '@pages/api/_environment/environmentApi', +vi.mock('@pages/api/_utils/environment', async () => { + const actual = await vi.importActual( + '@pages/api/_utils/environment', ) return { ...actual, diff --git a/src/pages/api/cron/cleanup-confirmations.ts b/src/pages/api/cron/cleanup-confirmations.ts index 6da7b7ae5..364f75d99 100644 --- a/src/pages/api/cron/cleanup-confirmations.ts +++ b/src/pages/api/cron/cleanup-confirmations.ts @@ -1,8 +1,11 @@ import type { APIRoute } from 'astro' import { db, lt, newsletterConfirmations } from 'astro:db' -import { getCronSecret } from '@pages/api/_environment/environmentApi' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' +import { getCronSecret } from '@pages/api/_utils/environment' +import { + ApiFunctionError, + buildApiErrorResponse, + handleApiFunctionError +} from '@pages/api/_utils/errors' import { createApiFunctionContext } from '@pages/api/_utils/requestContext' export const prerender = false diff --git a/src/pages/api/cron/cleanup-dsar-requests.ts b/src/pages/api/cron/cleanup-dsar-requests.ts index c69a34a80..3457f02d3 100644 --- a/src/pages/api/cron/cleanup-dsar-requests.ts +++ b/src/pages/api/cron/cleanup-dsar-requests.ts @@ -1,8 +1,11 @@ import type { APIRoute } from 'astro' import { and, db, dsarRequests, isNull, lt } from 'astro:db' -import { getCronSecret } from '@pages/api/_environment/environmentApi' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' +import { getCronSecret } from '@pages/api/_utils/environment' +import { + ApiFunctionError, + buildApiErrorResponse, + handleApiFunctionError +} from '@pages/api/_utils/errors' import { createApiFunctionContext } from '@pages/api/_utils/requestContext' export const prerender = false diff --git a/src/pages/api/cron/run-all.ts b/src/pages/api/cron/run-all.ts index fe76e37ec..5c6036a66 100644 --- a/src/pages/api/cron/run-all.ts +++ b/src/pages/api/cron/run-all.ts @@ -1,8 +1,10 @@ import type { APIRoute } from 'astro' -import { getCronSecret } from '@pages/api/_environment/environmentApi' -import { getSiteUrl } from '@pages/api/_environment/siteUrlApi' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' +import { getCronSecret, getSiteUrl } from '@pages/api/_utils/environment' +import { + ApiFunctionError, + buildApiErrorResponse, + handleApiFunctionError +} from '@pages/api/_utils/errors' import { createApiFunctionContext } from '@pages/api/_utils/requestContext' export const prerender = false diff --git a/src/pages/api/downloads/__tests__/_submit.spec.ts b/src/pages/api/downloads/__tests__/_submit.spec.ts deleted file mode 100644 index b8e8b5d14..000000000 --- a/src/pages/api/downloads/__tests__/_submit.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Unit tests for downloads form API endpoint - */ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { POST } from '@pages/api/downloads/submit' - -describe('Downloads API - POST /api/downloads/submit', () => { - beforeEach(() => { - // Suppress console output - vi.spyOn(console, 'log').mockImplementation(() => {}) - vi.spyOn(console, 'error').mockImplementation(() => {}) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - // Reset any state if needed - }) - - it('should accept valid download form submission', async () => { - const request = new Request('http://localhost/api/downloads/submit', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - firstName: 'John', - lastName: 'Doe', - workEmail: 'john.doe@company.com', - jobTitle: 'Software Engineer', - companyName: 'Acme Corp', - }), - }) - - const response = await POST({ request } as any) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.message).toContain('success') - }) - - it('should reject submission without firstName', async () => { - const request = new Request('http://localhost/api/downloads/submit', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - lastName: 'Doe', - workEmail: 'john.doe@company.com', - jobTitle: 'Software Engineer', - companyName: 'Acme Corp', - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('required') - }) - - it('should reject submission without lastName', async () => { - const request = new Request('http://localhost/api/downloads/submit', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - firstName: 'John', - workEmail: 'john.doe@company.com', - jobTitle: 'Software Engineer', - companyName: 'Acme Corp', - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('required') - }) - - it('should reject submission without workEmail', async () => { - const request = new Request('http://localhost/api/downloads/submit', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - firstName: 'John', - lastName: 'Doe', - jobTitle: 'Software Engineer', - companyName: 'Acme Corp', - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('required') - }) - - it('should reject submission without jobTitle', async () => { - const request = new Request('http://localhost/api/downloads/submit', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - firstName: 'John', - lastName: 'Doe', - workEmail: 'john.doe@company.com', - companyName: 'Acme Corp', - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('required') - }) - - it('should reject submission without companyName', async () => { - const request = new Request('http://localhost/api/downloads/submit', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - firstName: 'John', - lastName: 'Doe', - workEmail: 'john.doe@company.com', - jobTitle: 'Software Engineer', - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('required') - }) - - it('should reject submission with invalid email format', async () => { - const request = new Request('http://localhost/api/downloads/submit', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - firstName: 'John', - lastName: 'Doe', - workEmail: 'invalid-email', - jobTitle: 'Software Engineer', - companyName: 'Acme Corp', - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('Invalid email') - }) - - it('should handle malformed JSON gracefully', async () => { - const request = new Request('http://localhost/api/downloads/submit', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: 'invalid json', - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('Invalid JSON payload') - }) -}) diff --git a/src/pages/api/downloads/submit.ts b/src/pages/api/downloads/submit.ts deleted file mode 100644 index de81935fb..000000000 --- a/src/pages/api/downloads/submit.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * API endpoint for download form submissions - */ -import type { APIRoute } from 'astro' -import emailValidator from 'email-validator' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' -import { createApiFunctionContext } from '@pages/api/_utils/requestContext' - -export const prerender = false - -interface DownloadFormData { - firstName: string - lastName: string - workEmail: string - jobTitle: string - companyName: string -} - -const JSON_HEADERS = { - 'Content-Type': 'application/json', -} - -const REQUIRED_FIELDS: Array = [ - 'firstName', - 'lastName', - 'workEmail', - 'jobTitle', - 'companyName', -] - -const buildJsonResponse = (body: Record, status: number) => - new Response(JSON.stringify(body), { - status, - headers: JSON_HEADERS, - }) - -const validateDownloadForm = (payload: Partial): DownloadFormData => { - const normalized: DownloadFormData = { - firstName: payload.firstName?.trim() ?? '', - lastName: payload.lastName?.trim() ?? '', - workEmail: payload.workEmail?.trim() ?? '', - jobTitle: payload.jobTitle?.trim() ?? '', - companyName: payload.companyName?.trim() ?? '', - } - - const missingFields = REQUIRED_FIELDS.filter((field) => !normalized[field]) - - if (missingFields.length) { - throw new ApiFunctionError({ - message: 'All fields are required', - status: 400, - code: 'MISSING_FIELDS', - details: { missingFields }, - }) - } - - if (!emailValidator.validate(normalized.workEmail)) { - throw new ApiFunctionError({ - message: 'Invalid email address', - status: 400, - code: 'INVALID_EMAIL', - }) - } - - return normalized -} - -export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { - const { context: apiContext } = createApiFunctionContext({ - route: '/api/downloads/submit', - operation: 'POST', - request, - cookies, - clientAddress, - }) - - try { - let payload: Partial - try { - payload = await request.json() - } catch { - throw new ApiFunctionError({ - message: 'Invalid JSON payload', - status: 400, - code: 'INVALID_JSON', - details: { - route: '/api/downloads/submit', - }, - }) - } - - const data = validateDownloadForm(payload) - - // TODO: Integrate with email service (e.g., SendGrid, Mailchimp, HubSpot) - // TODO: Store submission in database or CRM - // For now, just log the submission - console.log('Download form submission:', { - name: `${data.firstName} ${data.lastName}`, - email: data.workEmail, - jobTitle: data.jobTitle, - company: data.companyName, - timestamp: new Date().toISOString(), - }) - - return buildJsonResponse( - { - success: true, - message: 'Form submitted successfully', - }, - 200, - ) - } catch (rawError) { - const normalizedError = - rawError instanceof ApiFunctionError - ? rawError - : rawError instanceof SyntaxError - ? new ApiFunctionError({ - message: 'Invalid JSON payload', - status: 400, - code: 'INVALID_JSON', - }) - : rawError - - const serverError = handleApiFunctionError(normalizedError, apiContext) - - return buildApiErrorResponse(serverError, { - fallbackMessage: 'Internal server error', - }) - } -} diff --git a/src/pages/api/gdpr/__tests__/dsarVerificationEmails.spec.ts b/src/pages/api/gdpr/__tests__/dsarVerificationEmails.spec.ts deleted file mode 100644 index a4276320d..000000000 --- a/src/pages/api/gdpr/__tests__/dsarVerificationEmails.spec.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' -import { TestError } from '@test/errors' -import { sendDSARVerificationEmail } from '@pages/api/gdpr/_dsarVerificationEmails' - -const { envMocks, siteUrlMock } = vi.hoisted(() => ({ - envMocks: { - isDev: vi.fn(() => false), - isTest: vi.fn(() => false), - getResendApiKey: vi.fn(() => 'test-resend-key'), - }, - siteUrlMock: vi.fn(() => 'https://webstackbuilders.com'), -})) - -vi.mock('@pages/api/_environment/environmentApi', () => envMocks) - -vi.mock('@pages/api/_environment/siteUrlApi', () => ({ - getSiteUrl: siteUrlMock, -})) - -// Create mock send function at module level -const mockSend = vi.fn() - -// Mock the Resend module -vi.mock('resend', () => { - return { - Resend: vi.fn(function ResendMock(_apiKey) { - return { - emails: { - send: mockSend, - }, - } - }), - } -}) - -describe('GDPR Email Utils', () => { - let consoleLogSpy: ReturnType - let consoleErrorSpy: ReturnType - - beforeEach(() => { - vi.resetModules() - vi.clearAllMocks() - - envMocks.isDev.mockReturnValue(false) - envMocks.isTest.mockReturnValue(false) - envMocks.getResendApiKey.mockReturnValue('test-resend-key') - siteUrlMock.mockReturnValue('https://webstackbuilders.com') - - consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - }) - - afterEach(() => { - consoleLogSpy.mockRestore() - consoleErrorSpy.mockRestore() - }) - - describe('sendDSARVerificationEmail', () => { - describe('in development/test environment', () => { - it('should log email details instead of sending when isDev() returns true', async () => { - envMocks.isDev.mockReturnValue(true) - - await sendDSARVerificationEmail('test@example.com', 'test-token', 'ACCESS') - - expect(consoleLogSpy).toHaveBeenCalledWith( - '[DEV/TEST MODE] DSAR verification email would be sent:', - { - email: 'test@example.com', - token: 'test-token', - requestType: 'ACCESS', - } - ) - expect(mockSend).not.toHaveBeenCalled() - }) - - it('should log email details instead of sending when isTest() returns true', async () => { - envMocks.isTest.mockReturnValue(true) - - await sendDSARVerificationEmail('test@example.com', 'test-token', 'DELETE') - - expect(consoleLogSpy).toHaveBeenCalledWith( - '[DEV/TEST MODE] DSAR verification email would be sent:', - { - email: 'test@example.com', - token: 'test-token', - requestType: 'DELETE', - } - ) - expect(mockSend).not.toHaveBeenCalled() - }) - }) - - describe('in production environment', () => { - - it('should send ACCESS verification email successfully', async () => { - mockSend.mockResolvedValue({ - data: { id: 'message-id-123' }, - error: null, - }) - - await sendDSARVerificationEmail('user@example.com', 'verification-token-123', 'ACCESS') - - expect(mockSend).toHaveBeenCalledTimes(1) - const callArgs = mockSend.mock.calls[0]?.[0] - expect(callArgs).toBeDefined() - - expect(callArgs!.from).toBe('Webstack Builders ') - expect(callArgs!.to).toBe('user@example.com') - expect(callArgs!.subject).toBe('Verify Your Data Access Request - Webstack Builders') - expect(callArgs!.html).toContain('Data Access Request') - expect(callArgs!.html).toContain('access your data') - expect(callArgs!.html).toContain('https://webstackbuilders.com/api/gdpr/verify?token=verification-token-123') - expect(callArgs!.text).toContain('Data Access Request') - expect(callArgs!.tags).toEqual([ - { name: 'type', value: 'gdpr-verification' }, - { name: 'request-type', value: 'access' }, - ]) - - expect(consoleLogSpy).toHaveBeenCalledWith( - '[DSAR Email] Verification sent successfully:', - { - email: 'user@example.com', - requestType: 'ACCESS', - messageId: 'message-id-123', - } - ) - }) - - it('should send DELETE verification email successfully with warning', async () => { - mockSend.mockResolvedValue({ - data: { id: 'message-id-456' }, - error: null, - }) - - await sendDSARVerificationEmail('user@example.com', 'delete-token-456', 'DELETE') - - expect(mockSend).toHaveBeenCalledTimes(1) - const callArgs = mockSend.mock.calls[0]?.[0] - expect(callArgs).toBeDefined() - - expect(callArgs!.subject).toBe('Verify Your Data Deletion Request - Webstack Builders') - expect(callArgs!.html).toContain('Data Deletion Request') - expect(callArgs!.html).toContain('delete your data') - expect(callArgs!.html).toContain('⚠️ Important') - expect(callArgs!.html).toContain('permanently delete all your data') - expect(callArgs!.text).toContain('⚠️ IMPORTANT') - expect(callArgs!.tags).toEqual([ - { name: 'type', value: 'gdpr-verification' }, - { name: 'request-type', value: 'delete' }, - ]) - }) - - it('should use getSiteUrl() return value for verification URL', async () => { - siteUrlMock.mockReturnValue('http://localhost:4321') - mockSend.mockResolvedValue({ - data: { id: 'message-id-789' }, - error: null, - }) - - await sendDSARVerificationEmail('user@example.com', 'token-789', 'ACCESS') - - const callArgs = mockSend.mock.calls[0]?.[0] - expect(callArgs).toBeDefined() - expect(callArgs!.html).toContain('http://localhost:4321/api/gdpr/verify?token=token-789') - expect(callArgs!.text).toContain('http://localhost:4321/api/gdpr/verify?token=token-789') - }) - - it('should handle Resend API error response', async () => { - mockSend.mockResolvedValue({ - data: null, - error: { - message: 'Invalid API key', - name: 'validation_error', - }, - }) - - await expect( - sendDSARVerificationEmail('user@example.com', 'token-123', 'ACCESS') - ).rejects.toThrow() - - expect(consoleErrorSpy).toHaveBeenCalledWith( - '[DSAR Email] Failed to send verification', - expect.objectContaining({ - message: 'Invalid API key', - name: 'validation_error', - }) - ) - }) - - it('should handle Resend API network error', async () => { - const networkError = new TestError('Network failure') - mockSend.mockRejectedValue(networkError) - - await expect( - sendDSARVerificationEmail('user@example.com', 'token-123', 'ACCESS') - ).rejects.toThrow() - - expect(consoleErrorSpy).toHaveBeenCalledWith( - '[DSAR Email] Error sending verification', - networkError - ) - }) - - it('should include current year in email content', async () => { - const currentYear = new Date().getFullYear() - mockSend.mockResolvedValue({ - data: { id: 'message-id-year' }, - error: null, - }) - - await sendDSARVerificationEmail('user@example.com', 'token-year', 'ACCESS') - - const callArgs = mockSend.mock.calls[0]?.[0] - expect(callArgs).toBeDefined() - expect(callArgs!.html).toContain(`© ${currentYear} Webstack Builders`) - expect(callArgs!.text).toContain(`© ${currentYear} Webstack Builders`) - }) - - it('should generate proper verification URLs with tokens', async () => { - siteUrlMock.mockReturnValue('https://example.com') - mockSend.mockResolvedValue({ - data: { id: 'message-id-url' }, - error: null, - }) - - await sendDSARVerificationEmail('user@example.com', 'special-token-123', 'DELETE') - - const callArgs = mockSend.mock.calls[0]?.[0] - expect(callArgs).toBeDefined() - const expectedUrl = 'https://example.com/api/gdpr/verify?token=special-token-123' - - expect(callArgs!.html).toContain(`href="${expectedUrl}"`) - expect(callArgs!.html).toContain(expectedUrl) // Also as plain text in email - expect(callArgs!.text).toContain(expectedUrl) - }) - }) - }) -}) \ No newline at end of file diff --git a/src/pages/api/gdpr/__tests__/request-data.spec.ts b/src/pages/api/gdpr/__tests__/request-data.spec.ts deleted file mode 100644 index 432cfdff8..000000000 --- a/src/pages/api/gdpr/__tests__/request-data.spec.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import type { AstroCookies } from 'astro' -import type { DSARRequestInput } from '@pages/api/_contracts/gdpr.contracts' - -const MOCK_TOKEN = 'mock-token' - -const mockCheckRateLimit = vi.fn() -const mockSendEmail = vi.fn() -const mockFindActiveRequest = vi.fn() -const mockCreateDsarRequest = vi.fn() - -vi.mock('uuid', () => ({ - v4: vi.fn(() => MOCK_TOKEN), -})) - -vi.mock('@pages/api/_utils', () => ({ - rateLimiters: { - export: { name: 'export' }, - }, - checkRateLimit: (...args: unknown[]) => mockCheckRateLimit(...args), -})) - -vi.mock('@pages/api/gdpr/_dsarVerificationEmails', () => ({ - sendDSARVerificationEmail: (...args: unknown[]) => mockSendEmail(...args), -})) - -vi.mock('@pages/api/gdpr/_utils/dsarStore', () => ({ - findActiveRequestByEmail: (...args: unknown[]) => mockFindActiveRequest(...args), - createDsarRequest: (...args: unknown[]) => mockCreateDsarRequest(...args), -})) - -import { POST } from '../request-data' - -const defaultRequestBody: DSARRequestInput = { - email: 'User@Example.com', - requestType: 'ACCESS', -} - -const createRequest = (body: DSARRequestInput) => - new Request('http://localhost/api/gdpr/request-data', { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'Content-Type': 'application/json', - }, - }) - -const cookies = { - get: vi.fn(() => undefined), -} as unknown as AstroCookies - -type PostArgs = Parameters[0] - -const createContext = (overrides?: Partial) => ({ - request: createRequest(defaultRequestBody), - clientAddress: '127.0.0.1', - cookies, - ...overrides, -}) as PostArgs - -describe('POST /api/gdpr/request-data', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue({ success: true, reset: undefined }) - mockSendEmail.mockResolvedValue(undefined) - mockFindActiveRequest.mockResolvedValue(undefined) - mockCreateDsarRequest.mockResolvedValue(undefined) - }) - - it('reuses an existing DSAR token when one is still active', async () => { - mockFindActiveRequest.mockResolvedValue({ token: 'existing-token' }) - - const response = await POST(createContext()) - const payload = await response.json() - - expect(response.status).toBe(200) - expect(payload).toEqual({ - success: true, - message: expect.stringContaining('Verification email sent'), - }) - expect(mockFindActiveRequest).toHaveBeenCalledWith('user@example.com', 'ACCESS') - expect(mockCreateDsarRequest).not.toHaveBeenCalled() - expect(mockSendEmail).toHaveBeenCalledWith('user@example.com', 'existing-token', 'ACCESS') - }) - - it('creates a new DSAR request when none exist', async () => { - mockFindActiveRequest.mockResolvedValue(undefined) - - const response = await POST( - createContext({ - request: createRequest({ - email: 'requester@example.com', - requestType: 'DELETE', - }), - }), - ) - const payload = await response.json() - - expect(response.status).toBe(201) - expect(payload).toEqual({ - success: true, - message: expect.stringContaining('Verification email sent'), - }) - expect(mockCreateDsarRequest).toHaveBeenCalledWith({ - token: MOCK_TOKEN, - email: 'requester@example.com', - requestType: 'DELETE', - expiresAt: expect.any(Date), - }) - expect(mockSendEmail).toHaveBeenLastCalledWith('requester@example.com', MOCK_TOKEN, 'DELETE') - }) -}) diff --git a/src/pages/api/gdpr/consent.ts b/src/pages/api/gdpr/consent.ts deleted file mode 100644 index 9267d06d0..000000000 --- a/src/pages/api/gdpr/consent.ts +++ /dev/null @@ -1,321 +0,0 @@ -import type { APIRoute } from 'astro' -import { getPrivacyPolicyVersion } from '@pages/api/_environment/environmentApi' -import { rateLimiters, checkRateLimit } from '@pages/api/_utils' -import { validate as uuidValidate } from 'uuid' -import type { ConsentRequest, ConsentResponse } from '@pages/api/_contracts/gdpr.contracts' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' -import { createApiFunctionContext, createRateLimitIdentifier } from '@pages/api/_utils/requestContext' -import { - createConsentRecord, - deleteConsentRecords, - findConsentRecords, - type ConsentEventRecord, -} from '@pages/api/gdpr/_utils/consentStore' - -export const prerender = false // Force SSR for this endpoint - -const ROUTE = '/api/gdpr/consent' - -const CONSENT_PURPOSES = ['contact', 'marketing', 'analytics', 'downloads'] as const -type ConsentPurpose = (typeof CONSENT_PURPOSES)[number] - -const CONSENT_SOURCES = ['contact_form', 'newsletter_form', 'download_form', 'cookies_modal', 'preferences_page'] as const -type ConsentSource = (typeof CONSENT_SOURCES)[number] - -const DEFAULT_SOURCE: ConsentSource = 'cookies_modal' -const DEFAULT_USER_AGENT = 'unknown' - -const isConsentPurpose = (value: unknown): value is ConsentPurpose => - typeof value === 'string' && CONSENT_PURPOSES.includes(value as ConsentPurpose) - -const isConsentSource = (value: unknown): value is ConsentSource => - typeof value === 'string' && CONSENT_SOURCES.includes(value as ConsentSource) - -const sanitizePurposes = (purposes: unknown): ConsentPurpose[] => - Array.isArray(purposes) ? purposes.filter(isConsentPurpose) : [] - -const sanitizeSource = (source: unknown): ConsentSource => (isConsentSource(source) ? source : DEFAULT_SOURCE) - -const normalizeNullableString = (value?: string | null): string | null => { - if (typeof value !== 'string') { - return null - } - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : null -} - -const normalizeUserAgent = (value?: string | null): string => normalizeNullableString(value) ?? DEFAULT_USER_AGENT - -const jsonResponse = (body: unknown, status: number, headers?: Record) => - new Response(JSON.stringify(body), { - status, - headers: { - 'Content-Type': 'application/json', - ...(headers || {}), - }, - }) - -const buildRateLimitError = (reset: number | undefined, message?: string) => { - const retryAfterMs = typeof reset === 'number' ? Math.max(0, reset - Date.now()) : 0 - const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000)) - return new ApiFunctionError({ - message: message ?? `Try again in ${retryAfterSeconds}s`, - status: 429, - code: 'RATE_LIMIT_EXCEEDED', - details: { retryAfterSeconds }, - }) -} - -const mapConsentRecord = (record: ConsentEventRecord): ConsentResponse['record'] => { - const normalizedEmail = normalizeNullableString(record.email) - const normalizedIpAddress = normalizeNullableString(record.ipAddress) - const normalizedConsentText = normalizeNullableString(record.consentText) - - const mapped: ConsentResponse['record'] = { - id: record.id, - DataSubjectId: record.dataSubjectId, - purposes: sanitizePurposes(record.purposes), - timestamp: record.createdAt instanceof Date ? record.createdAt.toISOString() : new Date(record.createdAt).toISOString(), - source: sanitizeSource(record.source), - userAgent: normalizeUserAgent(record.userAgent), - privacyPolicyVersion: record.privacyPolicyVersion ?? getPrivacyPolicyVersion(), - verified: record.verified, - } - - if (normalizedEmail) { - mapped.email = normalizedEmail - } - if (normalizedIpAddress) { - mapped.ipAddress = normalizedIpAddress - } - if (normalizedConsentText) { - mapped.consentText = normalizedConsentText - } - - return mapped -} - -const buildErrorResponse = ( - error: unknown, - context: ReturnType['context'], - fallbackMessage: string, -) => { - const serverError = handleApiFunctionError(error, context) - const retryAfterSecondsRaw = serverError.details?.['retryAfterSeconds'] - const options: { - fallbackMessage: string - headers?: HeadersInit - } = { - fallbackMessage, - } - - if (typeof retryAfterSecondsRaw === 'number') { - options.headers = { 'Retry-After': String(Math.max(1, Math.ceil(retryAfterSecondsRaw))) } - } - - return buildApiErrorResponse(serverError, options) -} - -export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { - const { context: apiContext, fingerprint } = createApiFunctionContext({ - route: ROUTE, - operation: 'POST', - request, - cookies, - clientAddress, - }) - - try { - const rateLimitIdentifier = createRateLimitIdentifier('gdpr:consent:post', fingerprint) - const { success, reset } = await checkRateLimit(rateLimiters.consent, rateLimitIdentifier) - if (!success) { - throw buildRateLimitError(reset) - } - - let body: ConsentRequest - try { - body = await request.json() - } catch { - throw new ApiFunctionError({ - message: 'Invalid JSON payload', - status: 400, - code: 'INVALID_JSON', - }) - } - - if (!uuidValidate(body.DataSubjectId)) { - throw new ApiFunctionError({ - message: 'Invalid DataSubjectId', - status: 400, - code: 'INVALID_UUID', - }) - } - - const normalizedEmail = normalizeNullableString(body.email ?? null) - const normalizedPurposes = sanitizePurposes(body.purposes) - const normalizedSource = sanitizeSource(body.source) - const normalizedUserAgent = normalizeUserAgent(body.userAgent) - const normalizedIpAddress = normalizeNullableString(body.ipAddress ?? null) - const normalizedConsentText = normalizeNullableString(body.consentText ?? null) - - let record: ConsentResponse['record'] - try { - const dbRecord = await createConsentRecord({ - dataSubjectId: body.DataSubjectId, - email: normalizedEmail, - purposes: normalizedPurposes, - source: normalizedSource, - userAgent: normalizedUserAgent, - ipAddress: normalizedIpAddress, - privacyPolicyVersion: getPrivacyPolicyVersion(), - consentText: normalizedConsentText, - verified: body.verified ?? false, - }) - record = mapConsentRecord(dbRecord) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'insert-consent', - status: 500, - details: { - dataSubjectId: body.DataSubjectId, - purposes: body.purposes, - }, - }) - } - - return jsonResponse( - { - success: true, - record, - } satisfies ConsentResponse, - 201, - ) - } catch (error) { - return buildErrorResponse(error, apiContext, 'Failed to record consent') - } -} - - -export const GET: APIRoute = async ({ clientAddress, url, request, cookies }) => { - const { context: apiContext, fingerprint } = createApiFunctionContext({ - route: ROUTE, - operation: 'GET', - request, - cookies, - clientAddress, - }) - - const DataSubjectId = url.searchParams.get('DataSubjectId') - const purpose = url.searchParams.get('purpose') - - try { - const rateLimitIdentifier = createRateLimitIdentifier('gdpr:consent:get', fingerprint) - const { success, reset } = await checkRateLimit(rateLimiters.consentRead, rateLimitIdentifier) - if (!success) { - throw buildRateLimitError(reset) - } - - if (!DataSubjectId || !uuidValidate(DataSubjectId)) { - throw new ApiFunctionError({ - message: 'Valid DataSubjectId required', - status: 400, - code: 'INVALID_UUID', - }) - } - - let fetchRecords: ConsentEventRecord[] - try { - fetchRecords = await findConsentRecords(DataSubjectId) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'GET.fetch-consent-records', - status: 500, - details: { - dataSubjectId: DataSubjectId, - purpose, - }, - }) - } - const filteredRecords = purpose - ? fetchRecords.filter(record => record.purposes.includes(purpose)) - : fetchRecords - const records = filteredRecords.map(mapConsentRecord) - - return jsonResponse( - { - success: true, - records, - hasActive: purpose ? records.length > 0 : undefined, - activeRecord: purpose && records.length > 0 ? records[0] : undefined, - }, - 200, - ) - } catch (error) { - apiContext.extra = { - ...(apiContext.extra || {}), - dataSubjectId: DataSubjectId, - purpose, - } - return buildErrorResponse(error, apiContext, 'Failed to retrieve consent') - } -} - - -export const DELETE: APIRoute = async ({ clientAddress, url, request, cookies }) => { - const { context: apiContext, fingerprint } = createApiFunctionContext({ - route: ROUTE, - operation: 'DELETE', - request, - cookies, - clientAddress, - }) - - const DataSubjectId = url.searchParams.get('DataSubjectId') - - try { - const rateLimitIdentifier = createRateLimitIdentifier('gdpr:consent:delete', fingerprint) - const { success, reset } = await checkRateLimit(rateLimiters.delete, rateLimitIdentifier) - if (!success) { - throw buildRateLimitError(reset) - } - - if (!DataSubjectId || !uuidValidate(DataSubjectId)) { - throw new ApiFunctionError({ - message: 'Valid DataSubjectId required', - status: 400, - code: 'INVALID_UUID', - }) - } - - let deletedCount: number - try { - deletedCount = await deleteConsentRecords(DataSubjectId) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'DELETE.remove-consent', - status: 500, - details: { - dataSubjectId: DataSubjectId, - }, - }) - } - - return jsonResponse( - { - success: true, - deletedCount, - }, - 200, - ) - } catch (error) { - apiContext.extra = { - ...(apiContext.extra || {}), - dataSubjectId: DataSubjectId, - } - return buildErrorResponse(error, apiContext, 'Failed to delete consent') - } -} diff --git a/src/pages/api/gdpr/export.ts b/src/pages/api/gdpr/export.ts deleted file mode 100644 index 93b4c2f48..000000000 --- a/src/pages/api/gdpr/export.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { APIRoute } from 'astro' -import { rateLimiters, checkRateLimit } from '@pages/api/_utils' -import { validate as uuidValidate } from 'uuid' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' -import { createApiFunctionContext, createRateLimitIdentifier } from '@pages/api/_utils/requestContext' -import { findConsentRecords } from '@pages/api/gdpr/_utils/consentStore' - -export const prerender = false // Force SSR for this endpoint - -const ROUTE = '/api/gdpr/export' - -const buildRateLimitError = (reset: number | undefined) => { - const retryAfterMs = typeof reset === 'number' ? Math.max(0, reset - Date.now()) : 0 - const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000)) - return new ApiFunctionError({ - message: `Try again in ${retryAfterSeconds}s`, - status: 429, - code: 'RATE_LIMIT_EXCEEDED', - details: { retryAfterSeconds }, - }) -} - -const buildErrorResponse = ( - error: unknown, - context: ReturnType['context'], - fallbackMessage: string, -) => { - const serverError = handleApiFunctionError(error, context) - const retryAfterSecondsRaw = serverError.details?.['retryAfterSeconds'] - const options: { - fallbackMessage: string - headers?: HeadersInit - } = { - fallbackMessage, - } - - if (typeof retryAfterSecondsRaw === 'number') { - options.headers = { 'Retry-After': String(Math.max(1, Math.ceil(retryAfterSecondsRaw))) } - } - - return buildApiErrorResponse(serverError, options) -} - - -export const GET: APIRoute = async ({ clientAddress, url, request, cookies }) => { - const { context: apiContext, fingerprint } = createApiFunctionContext({ - route: ROUTE, - operation: 'GET', - request, - cookies, - clientAddress, - }) - - const DataSubjectId = url.searchParams.get('DataSubjectId') - - try { - const rateLimitIdentifier = createRateLimitIdentifier('gdpr:export:get', fingerprint) - const { success, reset } = await checkRateLimit(rateLimiters.export, rateLimitIdentifier) - if (!success) { - throw buildRateLimitError(reset) - } - - if (!DataSubjectId || !uuidValidate(DataSubjectId)) { - throw new ApiFunctionError({ - message: 'Invalid DataSubjectId', - status: 400, - code: 'INVALID_UUID', - }) - } - - const consentRecords = await findConsentRecords(DataSubjectId) - const exportData = consentRecords.map((record) => ({ - id: record.id, - data_subject_id: record.dataSubjectId, - email: record.email, - purposes: record.purposes, - source: record.source, - user_agent: record.userAgent, - privacy_policy_version: record.privacyPolicyVersion, - consent_text: record.consentText, - verified: record.verified, - created_at: record.createdAt.toISOString(), - })) - - return new Response(JSON.stringify(exportData, null, 2), { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'Content-Disposition': `attachment; filename="my-data-${Date.now()}.json"` - } - }) - } catch (error) { - apiContext.extra = { - ...(apiContext.extra || {}), - dataSubjectId: DataSubjectId, - } - return buildErrorResponse(error, apiContext, 'Failed to export data') - } -} diff --git a/src/pages/api/gdpr/request-data.ts b/src/pages/api/gdpr/request-data.ts deleted file mode 100644 index c81d6c2d2..000000000 --- a/src/pages/api/gdpr/request-data.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { APIRoute } from 'astro' -import emailValidator from 'email-validator' -import { v4 as uuidv4 } from 'uuid' -import { rateLimiters, checkRateLimit } from '@pages/api/_utils' -import { sendDSARVerificationEmail } from '@pages/api/gdpr/_dsarVerificationEmails' -import type { DSARRequestInput, DSARResponse } from '@pages/api/_contracts/gdpr.contracts' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' -import { createApiFunctionContext, createRateLimitIdentifier } from '@pages/api/_utils/requestContext' -import { createDsarRequest, findActiveRequestByEmail } from '@pages/api/gdpr/_utils/dsarStore' - -export const prerender = false // Force SSR for this endpoint - -const ROUTE = '/api/gdpr/request-data' - -const jsonResponse = (body: unknown, status: number, headers?: Record) => - new Response(JSON.stringify(body), { - status, - headers: { - 'Content-Type': 'application/json', - ...(headers || {}), - }, - }) - -const buildRateLimitError = (reset: number | undefined) => { - const retryAfterMs = typeof reset === 'number' ? Math.max(0, reset - Date.now()) : 0 - const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000)) - return new ApiFunctionError({ - message: `Too many requests. Try again in ${retryAfterSeconds}s`, - status: 429, - code: 'RATE_LIMIT_EXCEEDED', - details: { retryAfterSeconds }, - }) -} - -const buildValidationError = (message: string) => - new ApiFunctionError({ - message, - status: 400, - code: 'INVALID_REQUEST', - }) - -const buildErrorResponse = ( - error: unknown, - context: ReturnType['context'], - fallbackMessage: string, -) => { - const serverError = handleApiFunctionError(error, context) - const retryAfterSecondsRaw = serverError.details?.['retryAfterSeconds'] - const options: { - fallbackMessage: string - headers?: HeadersInit - } = { - fallbackMessage, - } - - if (typeof retryAfterSecondsRaw === 'number') { - options.headers = { 'Retry-After': String(Math.max(1, Math.ceil(retryAfterSecondsRaw))) } - } - - return buildApiErrorResponse(serverError, options) -} - -/** - * POST /api/gdpr/request-data - * Initiates a DSAR (Data Subject Access Request) for data access or deletion - * Sends verification email with token - */ - -export const POST: APIRoute = async ({ request, clientAddress, cookies }) => { - const { context: apiContext, fingerprint } = createApiFunctionContext({ - route: ROUTE, - operation: 'POST', - request, - cookies, - clientAddress, - }) - - try { - const rateLimitIdentifier = createRateLimitIdentifier('gdpr:dsar:request', fingerprint) - const { success, reset } = await checkRateLimit(rateLimiters.export, rateLimitIdentifier) - if (!success) { - throw buildRateLimitError(reset) - } - - let body: DSARRequestInput - try { - body = await request.json() - } catch { - throw new ApiFunctionError({ - message: 'Invalid JSON payload', - status: 400, - code: 'INVALID_JSON', - }) - } - - if (!body.email || !body.requestType) { - throw buildValidationError('Email and request type are required') - } - - if (!emailValidator.validate(body.email)) { - throw buildValidationError('Invalid email format') - } - - if (!['ACCESS', 'DELETE'].includes(body.requestType)) { - throw buildValidationError('Request type must be ACCESS or DELETE') - } - - const email = body.email.toLowerCase().trim() - const token = uuidv4() - const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours - - let existingRequest - try { - existingRequest = await findActiveRequestByEmail(email, body.requestType) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'fetch-existing-request', - status: 500, - details: { - email, - requestType: body.requestType, - }, - }) - } - - if (existingRequest) { - // Resend verification email with existing token - await sendDSARVerificationEmail(email, existingRequest.token, body.requestType) - - return jsonResponse( - { - success: true, - message: 'Verification email sent. Please check your inbox.', - } satisfies DSARResponse, - 200, - ) - } - - try { - await createDsarRequest({ - token, - email, - requestType: body.requestType, - expiresAt, - }) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'create-request', - status: 500, - details: { - email, - requestType: body.requestType, - }, - }) - } - - // Send verification email - await sendDSARVerificationEmail(email, token, body.requestType) - - return jsonResponse( - { - success: true, - message: 'Verification email sent. Please check your inbox and click the link to complete your request.', - } satisfies DSARResponse, - 201, - ) - } catch (error) { - return buildErrorResponse(error, apiContext, 'Failed to process request. Please try again.') - } -} diff --git a/src/pages/api/gdpr/verify.ts b/src/pages/api/gdpr/verify.ts deleted file mode 100644 index 80bbf4c05..000000000 --- a/src/pages/api/gdpr/verify.ts +++ /dev/null @@ -1,218 +0,0 @@ -import type { APIRoute } from 'astro' -import { rateLimiters, checkRateLimit } from '@pages/api/_utils' -import type { DSARRequest } from '@pages/api/_contracts/gdpr.contracts' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' -import { createApiFunctionContext, createRateLimitIdentifier } from '@pages/api/_utils/requestContext' -import { - deleteConsentRecordsByEmail, - findConsentRecordsByEmail, -} from '@pages/api/gdpr/_utils/consentStore' -import { - findDsarRequestByToken, - markDsarRequestFulfilled, -} from '@pages/api/gdpr/_utils/dsarStore' -import { deleteNewsletterConfirmationsByEmail } from '@pages/api/newsletter/_token' - -export const prerender = false // Force SSR for this endpoint - -const ROUTE = '/api/gdpr/verify' - -const buildRateLimitError = (reset: number | undefined) => { - const retryAfterMs = typeof reset === 'number' ? Math.max(0, reset - Date.now()) : 0 - const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000)) - return new ApiFunctionError({ - message: `Too many requests. Try again in ${retryAfterSeconds}s`, - status: 429, - code: 'RATE_LIMIT_EXCEEDED', - details: { retryAfterSeconds }, - }) -} - -const buildErrorResponse = ( - error: unknown, - context: ReturnType['context'], - fallbackMessage: string, -) => { - const serverError = handleApiFunctionError(error, context) - const retryAfterSecondsRaw = serverError.details?.['retryAfterSeconds'] - const options: { - fallbackMessage: string - headers?: HeadersInit - } = { - fallbackMessage, - } - - if (typeof retryAfterSecondsRaw === 'number') { - options.headers = { 'Retry-After': String(Math.max(1, Math.ceil(retryAfterSecondsRaw))) } - } - - return buildApiErrorResponse(serverError, options) -} - -/** - * GET /api/gdpr/verify?token=xxx - * Verifies DSAR token and fulfills the request (data access or deletion) - */ -export const GET: APIRoute = async ({ request, clientAddress, cookies, redirect }) => { - const { context: apiContext, fingerprint } = createApiFunctionContext({ - route: ROUTE, - operation: 'GET', - request, - cookies, - clientAddress, - }) - - const url = new URL(request.url) - const token = url.searchParams.get('token') - const rateLimitIdentifier = createRateLimitIdentifier('gdpr:dsar:verify', fingerprint) - const { success, reset } = await checkRateLimit(rateLimiters.export, rateLimitIdentifier) - - if (!success) { - return buildErrorResponse(buildRateLimitError(reset), apiContext, 'Too many requests') - } - - if (!token) { - return buildErrorResponse( - new ApiFunctionError({ - message: 'Verification token is required', - status: 400, - code: 'INVALID_REQUEST', - }), - apiContext, - 'Verification token is required', - ) - } - - apiContext.extra = { ...(apiContext.extra || {}), token } - - try { - const dbRequest = await findDsarRequestByToken(token) - - if (!dbRequest) { - return redirect('/privacy/my-data?status=invalid') - } - - const dsarRequest: DSARRequest = { - id: dbRequest.id, - token: dbRequest.token, - email: dbRequest.email, - requestType: dbRequest.requestType as DSARRequest['requestType'], - expiresAt: dbRequest.expiresAt.toISOString(), - fulfilledAt: dbRequest.fulfilledAt?.toISOString(), - createdAt: dbRequest.createdAt.toISOString(), - } - - // Check if already fulfilled - if (dsarRequest.fulfilledAt) { - return redirect('/privacy/my-data?status=already-completed') - } - - // Check if expired - if (new Date(dsarRequest.expiresAt) < new Date()) { - return redirect('/privacy/my-data?status=expired') - } - - const email = dsarRequest.email - const requestType = dsarRequest.requestType - - if (requestType === 'ACCESS') { - let consentRecords - try { - consentRecords = await findConsentRecordsByEmail(email) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'fetch-consent-records', - status: 500, - details: { - email, - }, - }) - } - - try { - await markDsarRequestFulfilled(token) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'mark-request-fulfilled', - status: 500, - details: { - token, - requestType, - }, - }) - } - - // Return data as JSON download - const exportData = { - email, - requestDate: dsarRequest.createdAt, - consentRecords: consentRecords.map(({ ipAddress: _ip, ...record }) => ({ - ...record, - createdAt: record.createdAt instanceof Date ? record.createdAt.toISOString() : record.createdAt, - })), - } - - return new Response(JSON.stringify(exportData, null, 2), { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'Content-Disposition': `attachment; filename="my-data-${Date.now()}.json"` - } - }) - } else if (requestType === 'DELETE') { - try { - await deleteConsentRecordsByEmail(email) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'delete-consent-records', - status: 500, - details: { - email, - }, - }) - } - - try { - await deleteNewsletterConfirmationsByEmail(email) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'delete-newsletter-confirmations', - status: 500, - details: { - email, - }, - }) - } - - try { - await markDsarRequestFulfilled(token) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'mark-delete-request-fulfilled', - status: 500, - details: { - token, - }, - }) - } - - // Redirect to success page - return redirect('/privacy/my-data?status=deleted') - } - - return redirect('/privacy/my-data?status=error') - } catch (error) { - apiContext.extra = { - ...(apiContext.extra || {}), - clientAddress, - } - handleApiFunctionError(error, apiContext) - return redirect('/privacy/my-data?status=error') - } -} diff --git a/src/pages/api/health/index.ts b/src/pages/api/health/index.ts index 6d3454347..16a09c38d 100644 --- a/src/pages/api/health/index.ts +++ b/src/pages/api/health/index.ts @@ -6,7 +6,10 @@ */ import type { APIRoute } from 'astro' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' +import { + buildApiErrorResponse, + handleApiFunctionError +} from '@pages/api/_utils/errors' import { createApiFunctionContext } from '@pages/api/_utils/requestContext' export const prerender = false diff --git a/src/pages/api/newsletter/__tests__/_confirm.spec.ts b/src/pages/api/newsletter/__tests__/_confirm.spec.ts deleted file mode 100644 index 289804e12..000000000 --- a/src/pages/api/newsletter/__tests__/_confirm.spec.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * Unit tests for newsletter confirmation API endpoint - */ -import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest' -import type { APIContext } from 'astro' -import type { PendingSubscription } from '@pages/api/newsletter/_token' -import { TestError } from '@test/errors' -import { GET } from '@pages/api/newsletter/confirm' - -const consentMocks = vi.hoisted(() => ({ - markConsentRecordsVerified: vi.fn(), -})) - -// Mock dependencies -vi.mock('@pages/api/newsletter/_token', () => ({ - confirmSubscription: vi.fn(), -})) - -vi.mock('@pages/api/newsletter/_email', () => ({ - sendWelcomeEmail: vi.fn(), -})) - -vi.mock('@pages/api/gdpr/_utils/consentStore', () => consentMocks) - -const createRequestContext = (inputUrl: string): APIContext => { - const url = new URL(inputUrl) - const request = new Request(url.toString(), { - method: 'GET', - headers: { - 'user-agent': 'Test Browser', - }, - }) - - return { - request, - url, - params: {}, - locals: {}, - redirect: vi.fn(), - } as unknown as APIContext -} - -vi.mock('@pages/api/newsletter/index', () => ({ - subscribeToConvertKit: vi.fn(), -})) - -const tokenModule = await import('@pages/api/newsletter/_token') -const emailModule = await import('@pages/api/newsletter/_email') -const convertKitModule = await import('@pages/api/newsletter/index') -const consentStoreModule = await import('@pages/api/gdpr/_utils/consentStore') - -const mockConfirmSubscription = tokenModule.confirmSubscription as Mock -const mockSendWelcomeEmail = emailModule.sendWelcomeEmail as Mock -const mockSubscribeToConvertKit = convertKitModule.subscribeToConvertKit as Mock -const mockMarkConsentRecordsVerified = consentStoreModule.markConsentRecordsVerified as Mock - -const buildSubscription = (overrides: Partial = {}): PendingSubscription => ({ - email: 'test@example.com', - firstName: 'John', - DataSubjectId: 'data-subject-123', - token: 'valid-token-123', - createdAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - consentTimestamp: new Date().toISOString(), - userAgent: 'Test Browser', - ipAddress: '192.168.1.1', - verified: true, - source: 'newsletter_form', - ...overrides, -}) - -describe('Newsletter Confirmation API - GET /api/newsletter/confirm', () => { - beforeEach(() => { - vi.clearAllMocks() - // Suppress console output - vi.spyOn(console, 'log').mockImplementation(() => {}) - vi.spyOn(console, 'error').mockImplementation(() => {}) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - mockSendWelcomeEmail.mockResolvedValue(undefined) - mockMarkConsentRecordsVerified.mockResolvedValue(1) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('should confirm valid token and activate subscription', async () => { - const mockSubscription = buildSubscription() - - mockConfirmSubscription.mockResolvedValue(mockSubscription) - - const response = await GET(createRequestContext('http://localhost/api/newsletter/confirm?token=valid-token-123')) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.status).toBe('success') - expect(data.email).toBe('test@example.com') - expect(data.message).toContain('confirmed') - - // Verify consent verification helper call - expect(mockMarkConsentRecordsVerified).toHaveBeenCalledWith('test@example.com', 'data-subject-123') - expect(mockMarkConsentRecordsVerified).toHaveBeenCalledTimes(1) - - // Verify welcome email was sent (force mock disabled by default) - expect(mockSendWelcomeEmail).toHaveBeenCalledWith( - 'test@example.com', - 'John' - ) - - expect(mockSubscribeToConvertKit).toHaveBeenCalledWith( - expect.objectContaining({ email: 'test@example.com' }), - ) - }) - - it('should reject request without token', async () => { - const response = await GET(createRequestContext('http://localhost/api/newsletter/confirm')) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('No token provided') - }) - - it('should handle expired or invalid token', async () => { - mockConfirmSubscription.mockResolvedValue(null) - - const response = await GET(createRequestContext('http://localhost/api/newsletter/confirm?token=expired-token')) - const body = await response.json() - - expect(response.status).toBe(200) - expect(body.success).toBe(false) - expect(body.status).toBe('expired') - expect(body.message).toContain('expired') - }) - - it('should handle subscription without firstName', async () => { - const mockSubscription = buildSubscription({ firstName: undefined }) - - mockConfirmSubscription.mockResolvedValue(mockSubscription) - - const response = await GET(createRequestContext('http://localhost/api/newsletter/confirm?token=valid-token-123')) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(mockSendWelcomeEmail).toHaveBeenCalledWith( - 'test@example.com', - undefined - ) - }) - - it('should handle subscription without ipAddress', async () => { - const mockSubscription = buildSubscription({ ipAddress: undefined }) - - mockConfirmSubscription.mockResolvedValue(mockSubscription) - - const response = await GET(createRequestContext('http://localhost/api/newsletter/confirm?token=valid-token-123')) - - expect(response.status).toBe(200) - expect(mockMarkConsentRecordsVerified).toHaveBeenCalledWith('test@example.com', 'data-subject-123') - }) - - it('should continue even if welcome email fails', async () => { - const mockSubscription = buildSubscription() - - mockConfirmSubscription.mockResolvedValue(mockSubscription) - mockSendWelcomeEmail.mockRejectedValue(new TestError('Email service down')) - - const response = await GET(createRequestContext('http://localhost/api/newsletter/confirm?token=valid-token-123')) - const data = await response.json() - - // Should still succeed - expect(response.status).toBe(200) - expect(data.success).toBe(true) - }) - - it('should handle confirmation service errors', async () => { - mockConfirmSubscription.mockRejectedValue(new TestError('Database error')) - - const response = await GET(createRequestContext('http://localhost/api/newsletter/confirm?token=valid-token-123')) - const body = await response.json() - - expect(response.status).toBe(500) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('Unable to confirm subscription.') - }) - - it('should surface errors when consent verification fails', async () => { - const mockSubscription = buildSubscription() - mockConfirmSubscription.mockResolvedValue(mockSubscription) - mockMarkConsentRecordsVerified.mockRejectedValue(new TestError('Consent DB offline')) - - const response = await GET(createRequestContext('http://localhost/api/newsletter/confirm?token=valid-token-123')) - const body = await response.json() - - expect(response.status).toBe(500) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('Unable to confirm subscription.') - }) -}) diff --git a/src/pages/api/newsletter/__tests__/_index.spec.ts b/src/pages/api/newsletter/__tests__/_index.spec.ts deleted file mode 100644 index 577765fac..000000000 --- a/src/pages/api/newsletter/__tests__/_index.spec.ts +++ /dev/null @@ -1,263 +0,0 @@ -/** - * Unit tests for newsletter subscription API endpoint - */ -import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest' -import { TestError } from '@test/errors' -import { POST, OPTIONS } from '@pages/api/newsletter' - -const rateLimitMocks = vi.hoisted(() => ({ - rateLimiters: { - consent: {}, - }, - checkRateLimit: vi.fn().mockResolvedValue({ success: true }), -})) - -const consentMocks = vi.hoisted(() => ({ - recordConsent: vi.fn(), -})) - -// Mock dependencies -vi.mock('@pages/api/newsletter/_token', () => ({ - createPendingSubscription: vi.fn(), -})) - -vi.mock('@pages/api/newsletter/_email', () => ({ - sendConfirmationEmail: vi.fn(), -})) - -vi.mock('@pages/api/_logger', () => consentMocks) - -vi.mock('@pages/api/_utils/rateLimit', () => ({ - rateLimiters: rateLimitMocks.rateLimiters, - checkRateLimit: rateLimitMocks.checkRateLimit, - checkContactRateLimit: vi.fn(), -})) - -const tokenModule = await import('@pages/api/newsletter/_token') -const emailModule = await import('@pages/api/newsletter/_email') -const mockRecordConsent = consentMocks.recordConsent as Mock - -const mockCreatePendingSubscription = tokenModule.createPendingSubscription as Mock -const mockSendConfirmationEmail = emailModule.sendConfirmationEmail as Mock - -describe('Newsletter API - POST /api/newsletter', () => { - beforeEach(() => { - vi.clearAllMocks() - // Suppress console output - vi.spyOn(console, 'log').mockImplementation(() => {}) - vi.spyOn(console, 'error').mockImplementation(() => {}) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - mockCreatePendingSubscription.mockResolvedValue('test-token-123') - mockSendConfirmationEmail.mockResolvedValue(undefined) - mockRecordConsent.mockResolvedValue({ - id: 'test-consent-id', - email: 'test@example.com', - purposes: ['marketing'], - timestamp: '2025-10-31T00:00:00.000Z', - source: 'newsletter_form', - userAgent: 'test-agent', - privacyPolicyVersion: '2025-10-20', - verified: false - }) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('should accept valid newsletter subscription with consent', async () => { - const request = new Request('http://localhost/api/newsletter', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-forwarded-for': '192.168.1.1', - 'user-agent': 'Test Browser', - }, - body: JSON.stringify({ - email: 'test@example.com', - firstName: 'John', - consentGiven: true, - }), - }) - - const response = await POST({ request } as any) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.message).toContain('check your email') - expect(data.requiresConfirmation).toBe(true) - - // Verify mocks were called correctly - expect(mockRecordConsent).toHaveBeenCalledWith( - expect.objectContaining({ - email: 'test@example.com', - purposes: ['marketing'], - source: 'newsletter_form', - verified: false, - }), - ) - expect(mockCreatePendingSubscription).toHaveBeenCalledWith( - expect.objectContaining({ - email: 'test@example.com', - firstName: 'John', - }), - ) - expect(mockSendConfirmationEmail).toHaveBeenCalledWith( - 'test@example.com', - 'test-token-123', - 'John' - ) - }) - - it('should reject subscription without email', async () => { - const request = new Request('http://localhost/api/newsletter', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - consentGiven: true, - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('Email address is required') - }) - - it('should reject subscription with invalid email format', async () => { - const request = new Request('http://localhost/api/newsletter', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: 'invalid-email', - consentGiven: true, - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('invalid') - }) - - it('should reject subscription without consent', async () => { - const request = new Request('http://localhost/api/newsletter', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: 'test@example.com', - consentGiven: false, - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('consent') - }) - - it('should normalize email to lowercase', async () => { - const request = new Request('http://localhost/api/newsletter', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: 'TEST@EXAMPLE.COM', - consentGiven: true, - }), - }) - - await POST({ request } as any) - - expect(mockRecordConsent).toHaveBeenCalledWith( - expect.objectContaining({ - email: 'test@example.com', - }), - ) - }) - - it('should bypass rate limiting in test environment', async () => { - // In test/dev/CI environments, rate limiting is disabled - // This test verifies that we can make unlimited requests - const ip = '192.168.1.100' - const headers = { - 'Content-Type': 'application/json', - 'x-forwarded-for': ip, - } - - // Make 20 requests - normally limited to 10 per 15 minutes - // All should succeed because rate limiting is bypassed - for (let i = 0; i < 20; i++) { - const request = new Request('http://localhost/api/newsletter', { - method: 'POST', - headers, - body: JSON.stringify({ - email: `test${i}@example.com`, - consentGiven: true, - }), - }) - const response = await POST({ request } as any) - expect(response.status).toBe(200) - } - }) - - it('should handle missing firstName gracefully', async () => { - const request = new Request('http://localhost/api/newsletter', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: 'test@example.com', - consentGiven: true, - }), - }) - - const response = await POST({ request } as any) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(mockSendConfirmationEmail).toHaveBeenCalledWith( - 'test@example.com', - 'test-token-123', - undefined - ) - }) - - it('should handle service errors gracefully', async () => { - mockCreatePendingSubscription.mockRejectedValue(new TestError('Service unavailable')) - - const request = new Request('http://localhost/api/newsletter', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: 'test@example.com', - consentGiven: true, - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(500) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('Failed to process newsletter request.') - }) -}) - -describe('Newsletter API - OPTIONS /api/newsletter', () => { - it('should return CORS headers', async () => { - const response = await OPTIONS({} as any) - - expect(response.status).toBe(200) - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*') - expect(response.headers.get('Access-Control-Allow-Methods')).toContain('POST') - expect(response.headers.get('Access-Control-Allow-Headers')).toContain('Content-Type') - }) -}) diff --git a/src/pages/api/newsletter/confirm.ts b/src/pages/api/newsletter/confirm.ts deleted file mode 100644 index 4ae5d3c0f..000000000 --- a/src/pages/api/newsletter/confirm.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Newsletter Confirmation API Endpoint - * Handles token validation and subscription confirmation - * This is an Astro API route that runs server-side - */ -import type { APIRoute } from 'astro' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' -import { createApiFunctionContext } from '@pages/api/_utils/requestContext' -import { markConsentRecordsVerified } from '@pages/api/gdpr/_utils/consentStore' - -// These imports work in Astro API routes because they run server-side -import { confirmSubscription } from './_token' -import { sendWelcomeEmail } from './_email' - -export const prerender = false // Force SSR for this endpoint - -const ROUTE = '/api/newsletter/confirm' - -const jsonResponse = (body: Record, status: number) => - new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }) - -export const GET: APIRoute = async ({ url, request, cookies, clientAddress }) => { - const { context: apiContext } = createApiFunctionContext({ - route: ROUTE, - operation: 'GET', - request, - cookies, - clientAddress, - }) - - const token = url.searchParams.get('token') - apiContext.extra = { ...(apiContext.extra || {}), token } - - try { - if (!token) { - throw new ApiFunctionError({ - message: 'No token provided', - status: 400, - code: 'TOKEN_REQUIRED', - }) - } - - // Validate and confirm the subscription - const subscription = await confirmSubscription(token) - - if (!subscription) { - return jsonResponse( - { - success: false, - status: 'expired', - message: 'This confirmation link has expired or been used already.', - }, - 200, - ) - } - - try { - await markConsentRecordsVerified(subscription.email, subscription.DataSubjectId) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'verify-consent-record', - status: 500, - details: { - email: subscription.email, - dataSubjectId: subscription.DataSubjectId, - }, - }) - } - - // Send welcome email (non-blocking, don't fail if it errors) - try { - await sendWelcomeEmail(subscription.email, subscription.firstName) - } catch (emailError) { - handleApiFunctionError(emailError, { - ...apiContext, - operation: 'send-welcome-email', - extra: { - email: subscription.email, - }, - }) - } - - // Add to ConvertKit with verified status - try { - const { subscribeToConvertKit } = await import('@pages/api/newsletter/index') - await subscribeToConvertKit({ - email: subscription.email, - ...(subscription.firstName ? { firstName: subscription.firstName } : {}), - }) - } catch (convertKitError) { - handleApiFunctionError(convertKitError, { - ...apiContext, - operation: 'subscribe-convertkit', - extra: { - email: subscription.email, - }, - }) - } - - return jsonResponse( - { - success: true, - status: 'success', - email: subscription.email, - message: 'Your subscription has been confirmed!', - }, - 200, - ) - } catch (error) { - const serverError = handleApiFunctionError(error, apiContext) - - return buildApiErrorResponse(serverError, { - fallbackMessage: 'Unable to confirm subscription.', - }) - } -} diff --git a/src/pages/api/newsletter/index.ts b/src/pages/api/newsletter/index.ts deleted file mode 100644 index 5026bf282..000000000 --- a/src/pages/api/newsletter/index.ts +++ /dev/null @@ -1,317 +0,0 @@ -/** - * Astro API endpoint for ConvertKit newsletter subscription - * Implements GDPR-compliant double opt-in flow - * - * With Vercel adapter, this becomes a serverless function automatically - */ -import type { APIRoute } from 'astro' -import { v4 as uuidv4, validate as uuidValidate } from 'uuid' -import emailValidator from 'email-validator' -import { getConvertkitApiKey, isDev, isTest } from '@pages/api/_environment/environmentApi' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' -import { rateLimiters, checkRateLimit } from '@pages/api/_utils/rateLimit' -import { createApiFunctionContext, createRateLimitIdentifier } from '@pages/api/_utils/requestContext' -import { recordConsent } from '@pages/api/_logger' -import { createPendingSubscription } from './_token' -import { sendConfirmationEmail } from './_email' - -export const prerender = false // Force SSR for this endpoint - -// Types -interface NewsletterFormData { - email: string - firstName?: string - consentGiven?: boolean - DataSubjectId?: string // Optional - will be generated if not provided -} - -interface ConvertKitSubscriber { - email_address: string - first_name?: string - state?: 'active' | 'inactive' - fields?: Record -} - -interface ConvertKitResponse { - subscriber: { - id: number - first_name: string | null - email_address: string - state: string - created_at: string - fields: Record - } -} - -interface ConvertKitErrorResponse { - errors: string[] -} - -/** - * Validate email address format and length - */ -function validateEmail(email: string): string { - if (!email) { - throw new ApiFunctionError({ - message: 'Email address is required.', - status: 400, - code: 'INVALID_EMAIL', - route: '/api/newsletter', - operation: 'validateEmail' - }) - } - - // RFC 5321 specifies max email length of 254 characters - if (email.length > 254) { - throw new ApiFunctionError({ - message: 'Email address is too long', - status: 400, - code: 'INVALID_EMAIL', - route: '/api/newsletter', - operation: 'validateEmail' - }) - } - - if (!emailValidator.validate(email)) { - throw new ApiFunctionError({ - message: 'Email address is invalid', - status: 400, - code: 'INVALID_EMAIL', - route: '/api/newsletter', - operation: 'validateEmail' - }) - } - - return email.trim().toLowerCase() -} - -/** - * Subscribe email to ConvertKit - */ -export async function subscribeToConvertKit( - data: NewsletterFormData -): Promise { - // Skip actual ConvertKit API call in dev/test environments - if (isDev() || isTest()) { - console.log('[DEV/TEST MODE] Newsletter subscription would be created:', { email: data.email }) - // Return mock success response - return { - subscriber: { - id: 999999, - state: 'active', - email_address: data.email, - first_name: data.firstName || null, - created_at: new Date().toISOString(), - fields: {}, - }, - } - } - - const subscriberData: ConvertKitSubscriber = { - email_address: data.email, - state: 'active', - } - - if (data.firstName) { - subscriberData.first_name = data.firstName.trim() - } - - try { - const response = await fetch('https://api.kit.com/v4/subscribers', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Kit-Api-Key': getConvertkitApiKey(), - }, - body: JSON.stringify(subscriberData), - }) - - const responseData = await response.json() - - if (response.status === 401) { - const errorData = responseData as ConvertKitErrorResponse - console.error('ConvertKit API authentication failed:', errorData.errors) - throw new ApiFunctionError({ - message: 'Newsletter service configuration error. Please contact support.', - status: 502, - code: 'CONVERTKIT_AUTH', - route: '/api/newsletter', - operation: 'subscribeToConvertKit' - }) - } - - if (response.status === 422) { - const errorData = responseData as ConvertKitErrorResponse - throw new ApiFunctionError({ - message: errorData.errors[0] || 'Invalid email address', - status: 400, - code: 'INVALID_EMAIL', - route: '/api/newsletter', - operation: 'subscribeToConvertKit' - }) - } - - if (response.status === 200 || response.status === 201 || response.status === 202) { - return responseData as ConvertKitResponse - } - - throw new ApiFunctionError({ - message: 'An unexpected error occurred. Please try again later.', - status: 502, - code: 'CONVERTKIT_UNKNOWN', - route: '/api/newsletter', - operation: 'subscribeToConvertKit' - }) - } catch (error) { - throw new ApiFunctionError({ - message: 'Failed to connect to newsletter service. Please try again later.', - cause: error, - status: 502, - code: 'CONVERTKIT_NETWORK', - route: '/api/newsletter', - operation: 'subscribeToConvertKit' - }) - } -} - -/** - * Main API handler for newsletter subscriptions - */ -export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { - const { context: apiContext, fingerprint } = createApiFunctionContext({ - route: '/api/newsletter', - operation: 'POST', - request, - cookies, - clientAddress, - }) - - const userAgent = request.headers.get('user-agent') || 'unknown' - apiContext.extra = { ...(apiContext.extra || {}), userAgent } - - try { - const rateLimitIdentifier = createRateLimitIdentifier('newsletter:consent', fingerprint) - const consentLimiter = rateLimiters['consent'] - - if (!consentLimiter) { - throw new ApiFunctionError({ - message: 'Rate limiting is not configured for newsletter subscriptions.', - status: 500, - code: 'RATE_LIMIT_NOT_CONFIGURED', - }) - } - - const { success, reset } = await checkRateLimit(consentLimiter, rateLimitIdentifier) - - if (!success) { - const retryAfterMs = typeof reset === 'number' ? Math.max(0, reset - Date.now()) : 0 - const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000)) - throw new ApiFunctionError({ - message: `Try again in ${retryAfterSeconds}s`, - status: 429, - code: 'RATE_LIMIT_EXCEEDED', - details: { retryAfterSeconds }, - }) - } - - let body: NewsletterFormData - try { - body = await request.json() - } catch { - throw new ApiFunctionError({ - message: 'Invalid JSON payload', - status: 400, - code: 'INVALID_JSON', - }) - } - - const { email, firstName, consentGiven, DataSubjectId } = body - const validatedEmail = validateEmail(email) - - if (!consentGiven) { - throw new ApiFunctionError({ - message: 'You must consent to receive marketing emails to subscribe.', - status: 400, - code: 'CONSENT_REQUIRED', - }) - } - - let subjectId = DataSubjectId - if (!subjectId) { - subjectId = uuidv4() - } else if (!uuidValidate(subjectId)) { - throw new ApiFunctionError({ - message: 'Invalid DataSubjectId format', - status: 400, - code: 'INVALID_UUID', - }) - } - - await recordConsent({ - origin: new URL(request.url).origin, - DataSubjectId: subjectId, - email: validatedEmail, - purposes: ['marketing'], - source: 'newsletter_form', - userAgent, - ...(clientAddress && clientAddress !== 'unknown' && { ipAddress: clientAddress }), - verified: false, - }) - - const token = await createPendingSubscription({ - email: validatedEmail, - ...(firstName && { firstName }), - DataSubjectId: subjectId, - userAgent, - ...(clientAddress && clientAddress !== 'unknown' && { ipAddress: clientAddress }), - source: 'newsletter_form', - }) - - await sendConfirmationEmail(validatedEmail, token, firstName) - - return new Response( - JSON.stringify({ - success: true, - message: 'Please check your email to confirm your subscription.', - requiresConfirmation: true, - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }, - ) - } catch (error) { - const serverError = handleApiFunctionError(error, apiContext) - const retryAfterSecondsRaw = serverError.details?.['retryAfterSeconds'] - const retryAfterSeconds = - typeof retryAfterSecondsRaw === 'number' - ? Math.max(1, Math.ceil(retryAfterSecondsRaw)) - : undefined - - const responseOptions: { - fallbackMessage: string - headers?: HeadersInit - } = { - fallbackMessage: 'Failed to process newsletter request.', - } - - if (retryAfterSeconds) { - responseOptions.headers = { 'Retry-After': String(retryAfterSeconds) } - } - - return buildApiErrorResponse(serverError, responseOptions) - } -} - -// Handle OPTIONS for CORS -export const OPTIONS: APIRoute = async () => { - return new Response(null, { - status: 200, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - }, - }) -} diff --git a/src/pages/api/social-card/index.ts b/src/pages/api/social-card/index.ts index a9a1fb097..e5b5b0a64 100644 --- a/src/pages/api/social-card/index.ts +++ b/src/pages/api/social-card/index.ts @@ -2,7 +2,10 @@ import { fileURLToPath } from 'node:url' import type { APIRoute } from 'astro' import { getCollection } from 'astro:content' import { generateOpenGraphImage } from 'astro-og-canvas' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' +import { + buildApiErrorResponse, + handleApiFunctionError +} from '@pages/api/_utils/errors' import { createApiFunctionContext } from '@pages/api/_utils/requestContext' export const prerender = false diff --git a/src/pages/newsletter/confirm/[token].astro b/src/pages/newsletter/confirm/[token].astro index 6b637e67c..99b2642ed 100644 --- a/src/pages/newsletter/confirm/[token].astro +++ b/src/pages/newsletter/confirm/[token].astro @@ -172,6 +172,8 @@ const subtitle = 'Please wait while we confirm your subscription'