Skip to content

Commit 0454cda

Browse files
committed
Update src/pages/api files to use new ApiFunctionError class and import helpers from local environment file to ensure no cyclical dependencies / invalid SSR code
1 parent 5e7bc1d commit 0454cda

21 files changed

Lines changed: 918 additions & 499 deletions

File tree

‎src/integrations/FaviconGenerator/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { writeFile, mkdir } from 'fs/promises'
2121
import { existsSync } from 'fs'
2222
import { resolve } from 'node:path'
2323
import type { AstroIntegration } from 'astro'
24-
import { BuildError } from '../../lib/errors/index.js'
24+
import { BuildError } from '../../lib/errors/BuildError'
2525

2626
type IconGenerator = (_options: Metadata) => Promise<Buffer>
2727

‎src/pages/api/_utils/__tests__/rateLimit.spec.ts‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
22

33
// Mock environment utilities BEFORE importing the module under test
4-
vi.mock('@components/scripts/utils/environmentClient', () => ({
4+
vi.mock('@pages/api/_environment', () => ({
5+
getUpstashApiToken: vi.fn(() => 'test-token'),
6+
getUpstashApiUrl: vi.fn(() => 'https://test-redis.upstash.io'),
57
isDev: vi.fn(() => false),
68
isTest: vi.fn(() => false),
79
}))
810

9-
// Mock astro:env/server
10-
vi.mock('astro:env/server', () => ({
11-
KV_REST_API_URL: 'https://test-redis.upstash.io',
12-
KV_REST_API_TOKEN: 'test-token',
13-
}))
14-
1511
import { rateLimiters, checkRateLimit, checkContactRateLimit } from '@pages/api/_utils/rateLimit'
1612

1713
// Mock the Upstash Redis and Ratelimit modules
@@ -249,7 +245,7 @@ describe('Rate Limit Utils', () => {
249245

250246
beforeEach(async () => {
251247
// Get the mocked functions
252-
const utils = await import('@components/scripts/utils/environmentClient')
248+
const utils = await import('@lib/config/environmentServer')
253249
isDev = utils.isDev as ReturnType<typeof vi.fn>
254250
isTest = utils.isTest as ReturnType<typeof vi.fn>
255251

‎src/pages/api/_utils/rateLimit.ts‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import { Ratelimit } from '@upstash/ratelimit'
22
import { Redis } from '@upstash/redis'
3-
import { KV_REST_API_URL, KV_REST_API_TOKEN } from 'astro:env/server'
4-
import { isDev, isTest } from '@components/scripts/utils/environmentClient'
3+
import {
4+
getUpstashApiToken,
5+
getUpstashApiUrl,
6+
isDev,
7+
isTest,
8+
} from '@pages/api/_environment'
59

610
const redis = new Redis({
7-
url: KV_REST_API_URL,
8-
token: KV_REST_API_TOKEN,
11+
url: getUpstashApiUrl(),
12+
token: getUpstashApiToken(),
913
})
1014

1115
// Simple in-memory rate limiting (use Redis in production)
@@ -51,10 +55,11 @@ export async function checkRateLimit(
5155
}
5256

5357
/**
54-
* Check if the IP address has exceeded the contact form rate limit
55-
* Disabled in development and CI environments
58+
* Check if the client fingerprint (hashed IP/UA) exceeded the contact form limit.
59+
* Disabled in development and CI environments. Callers should hash PII before
60+
* invoking this helper to keep rate-limiting compliant with GDPR requirements.
5661
*/
57-
export function checkContactRateLimit(ip: string): boolean {
62+
export function checkContactRateLimit(ipFingerprint: string): boolean {
5863
// Skip rate limiting in dev/test environments
5964
if (isDev() || isTest()) {
6065
return true
@@ -63,7 +68,7 @@ export function checkContactRateLimit(ip: string): boolean {
6368
const now = Date.now()
6469
const windowMs = 15 * 60 * 1000 // 15 minutes
6570
const maxRequests = 5 // Lower limit for contact form
66-
const key = `contact_rate_limit_${ip}`
71+
const key = `contact_rate_limit_${ipFingerprint}`
6772
const requests = rateLimitStore.get(key) || []
6873

6974
const validRequests = requests.filter((timestamp) => now - timestamp < windowMs)

‎src/pages/api/_utils/suprabase.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { createClient } from '@supabase/supabase-js'
2-
import { SUPABASE_URL } from 'astro:env/client'
3-
import { SUPABASE_SERVICE_ROLE_KEY } from 'astro:env/server'
2+
import { getSuprabaseApiServiceRoleKey, getSuprabaseApiUrl } from '@pages/api/_environment'
43

54
// Admin client (server-side only, bypasses RLS)
65
export const supabaseAdmin = createClient(
7-
SUPABASE_URL,
8-
SUPABASE_SERVICE_ROLE_KEY,
6+
getSuprabaseApiUrl(),
7+
getSuprabaseApiServiceRoleKey(),
98
{
109
auth: {
1110
autoRefreshToken: false,

‎src/pages/api/contact/index.ts‎

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
import type { APIRoute } from 'astro'
88
import { Resend } from 'resend'
99
import { v4 as uuidv4, validate as uuidValidate } from 'uuid'
10-
import { RESEND_API_KEY } from 'astro:env/server'
11-
import { ClientScriptError } from '@components/scripts/errors'
12-
import { isDev, isTest } from '@components/scripts/utils/environmentClient'
10+
import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError'
11+
import { handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler'
12+
import { getResendApiKey, isDev, isTest } from '@pages/api/_environment'
1313
import { checkContactRateLimit } from '@pages/api/_utils'
1414

1515
export const prerender = false // Force SSR for this endpoint
@@ -176,7 +176,7 @@ async function sendEmail(emailData: EmailData, files: FileAttachment[]): Promise
176176
return
177177
}
178178

179-
const resend = new Resend(RESEND_API_KEY)
179+
const resend = new Resend(getResendApiKey())
180180

181181
try {
182182
// Prepare attachments for Resend
@@ -194,15 +194,24 @@ async function sendEmail(emailData: EmailData, files: FileAttachment[]): Promise
194194
})
195195

196196
if (!response.data) {
197-
throw new ClientScriptError({
198-
message: response.error?.message || 'Failed to send email'
199-
})
197+
throw new ApiFunctionError({
198+
message: response.error?.message || 'Failed to send email',
199+
code: 'RESEND_SEND_FAILED',
200+
status: 502,
201+
route: '/api/contact',
202+
operation: 'sendEmail'
203+
})
200204
}
201205
} catch (error) {
202206
console.error('Resend API error:', error)
203-
throw new ClientScriptError({
204-
message: 'Failed to send email. Please try again later.'
205-
})
207+
throw new ApiFunctionError({
208+
message: 'Failed to send email. Please try again later.',
209+
cause: error,
210+
code: 'RESEND_SEND_FAILED',
211+
status: 502,
212+
route: '/api/contact',
213+
operation: 'sendEmail'
214+
})
206215
}
207216
}
208217

@@ -411,18 +420,24 @@ export const POST: APIRoute = async ({ request }) => {
411420
},
412421
)
413422
} catch (error) {
414-
console.error('Contact form error:', error)
423+
const serverError = handleApiFunctionError(error, {
424+
route: '/api/contact',
425+
operation: 'POST',
426+
extra: {
427+
userAgent: request.headers.get('user-agent') || 'unknown',
428+
},
429+
})
415430

416431
const errorMessage =
417-
error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.'
432+
serverError instanceof Error ? serverError.message : 'An unexpected error occurred. Please try again.'
418433

419434
return new Response(
420435
JSON.stringify({
421436
success: false,
422437
error: errorMessage,
423438
}),
424439
{
425-
status: 500,
440+
status: serverError.status ?? 500,
426441
headers: { 'Content-Type': 'application/json' },
427442
},
428443
)

‎src/pages/api/cron/cleanup-confirmations.ts‎

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { APIRoute } from 'astro'
2-
import { CRON_SECRET } from 'astro:env/server'
2+
import { getCronSecret } from '@pages/api/_environment'
33
import { supabaseAdmin } from '@pages/api/_utils'
4-
import { ClientScriptError } from '@components/scripts/errors'
4+
import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError'
5+
import { handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler'
56

67
export const prerender = false
78

@@ -16,7 +17,7 @@ export const GET: APIRoute = async ({ request }) => {
1617
// Verify cron secret for security
1718
const authHeader = request.headers.get('authorization')
1819

19-
if (authHeader !== `Bearer ${CRON_SECRET}`) {
20+
if (authHeader !== `Bearer ${getCronSecret()}`) {
2021
console.warn('Unauthorized cron attempt - invalid CRON_SECRET')
2122
return new Response(
2223
JSON.stringify({ success: false, error: 'Unauthorized' }),
@@ -35,10 +36,15 @@ export const GET: APIRoute = async ({ request }) => {
3536
.lt('expires_at', now.toISOString())
3637
.select()
3738

38-
if (expiredError) {
39-
throw new ClientScriptError({
40-
message: `Failed to delete expired tokens: ${expiredError.message}`
41-
})
39+
if (expiredError) {
40+
throw new ApiFunctionError({
41+
message: `Failed to delete expired tokens: ${expiredError.message}`,
42+
cause: expiredError,
43+
code: 'CRON_DELETE_EXPIRED_TOKENS_FAILED',
44+
status: 500,
45+
route: '/api/cron/cleanup-confirmations',
46+
operation: 'deleteExpiredTokens'
47+
})
4248
}
4349

4450
// Delete old confirmed records (7+ days old)
@@ -49,10 +55,15 @@ export const GET: APIRoute = async ({ request }) => {
4955
.lt('created_at', sevenDaysAgo.toISOString())
5056
.select()
5157

52-
if (confirmedError) {
53-
throw new ClientScriptError({
54-
message: `Failed to delete old confirmed records: ${confirmedError.message}`
55-
})
58+
if (confirmedError) {
59+
throw new ApiFunctionError({
60+
message: `Failed to delete old confirmed records: ${confirmedError.message}`,
61+
cause: confirmedError,
62+
code: 'CRON_DELETE_CONFIRMED_TOKENS_FAILED',
63+
status: 500,
64+
route: '/api/cron/cleanup-confirmations',
65+
operation: 'deleteConfirmedRecords'
66+
})
5667
}
5768

5869
const expiredCount = expiredData?.length || 0
@@ -76,13 +87,16 @@ export const GET: APIRoute = async ({ request }) => {
7687
{ status: 200, headers: { 'Content-Type': 'application/json' } }
7788
)
7889
} catch (error) {
79-
console.error('Newsletter confirmations cleanup failed:', error)
90+
const serverError = handleApiFunctionError(error, {
91+
route: '/api/cron/cleanup-confirmations',
92+
operation: 'GET',
93+
})
8094
return new Response(
8195
JSON.stringify({
8296
success: false,
83-
error: error instanceof Error ? error.message : 'Unknown error',
97+
error: serverError.message,
8498
}),
85-
{ status: 500, headers: { 'Content-Type': 'application/json' } }
99+
{ status: serverError.status ?? 500, headers: { 'Content-Type': 'application/json' } }
86100
)
87101
}
88102
}

‎src/pages/api/cron/cleanup-dsar-requests.ts‎

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { APIRoute } from 'astro'
2-
import { CRON_SECRET } from 'astro:env/server'
2+
import { getCronSecret } from '@pages/api/_environment'
33
import { supabaseAdmin } from '@pages/api/_utils'
4-
import { ClientScriptError } from '@components/scripts/errors'
4+
import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError'
5+
import { handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler'
56

67
export const prerender = false
78

@@ -15,7 +16,7 @@ export const prerender = false
1516
export const GET: APIRoute = async ({ request }) => {
1617
// Verify cron secret for security
1718
const authHeader = request.headers.get('authorization')
18-
if (authHeader !== `Bearer ${CRON_SECRET}`) {
19+
if (authHeader !== `Bearer ${getCronSecret()}`) {
1920
console.warn('Unauthorized cron attempt - invalid CRON_SECRET')
2021
return new Response(
2122
JSON.stringify({ success: false, error: 'Unauthorized' }),
@@ -36,10 +37,15 @@ export const GET: APIRoute = async ({ request }) => {
3637
.lt('fulfilled_at', thirtyDaysAgo.toISOString())
3738
.select()
3839

39-
if (fulfilledError) {
40-
throw new ClientScriptError({
41-
message: `Failed to delete fulfilled requests: ${fulfilledError.message}`
42-
})
40+
if (fulfilledError) {
41+
throw new ApiFunctionError({
42+
message: `Failed to delete fulfilled requests: ${fulfilledError.message}`,
43+
cause: fulfilledError,
44+
code: 'CRON_DELETE_FULFILLED_DSAR_FAILED',
45+
status: 500,
46+
route: '/api/cron/cleanup-dsar-requests',
47+
operation: 'deleteFulfilledRequests'
48+
})
4349
}
4450

4551
// Delete expired unfulfilled requests (7+ days old, never fulfilled)
@@ -50,10 +56,15 @@ export const GET: APIRoute = async ({ request }) => {
5056
.lt('created_at', sevenDaysAgo.toISOString())
5157
.select()
5258

53-
if (expiredError) {
54-
throw new ClientScriptError({
55-
message: `Failed to delete expired requests: ${expiredError.message}`
56-
})
59+
if (expiredError) {
60+
throw new ApiFunctionError({
61+
message: `Failed to delete expired requests: ${expiredError.message}`,
62+
cause: expiredError,
63+
code: 'CRON_DELETE_EXPIRED_DSAR_FAILED',
64+
status: 500,
65+
route: '/api/cron/cleanup-dsar-requests',
66+
operation: 'deleteExpiredRequests'
67+
})
5768
}
5869

5970
const fulfilledCount = fulfilledData?.length || 0
@@ -77,13 +88,16 @@ export const GET: APIRoute = async ({ request }) => {
7788
{ status: 200, headers: { 'Content-Type': 'application/json' } }
7889
)
7990
} catch (error) {
80-
console.error('DSAR requests cleanup failed:', error)
91+
const serverError = handleApiFunctionError(error, {
92+
route: '/api/cron/cleanup-dsar-requests',
93+
operation: 'GET',
94+
})
8195
return new Response(
8296
JSON.stringify({
8397
success: false,
84-
error: error instanceof Error ? error.message : 'Unknown error',
98+
error: serverError.message,
8599
}),
86-
{ status: 500, headers: { 'Content-Type': 'application/json' } }
100+
{ status: serverError.status ?? 500, headers: { 'Content-Type': 'application/json' } }
87101
)
88102
}
89103
}

‎src/pages/api/downloads/__tests__/_submit.spec.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,8 @@ describe('Downloads API - POST /api/downloads/submit', () => {
165165
const response = await POST({ request } as any)
166166
const data = await response.json()
167167

168-
expect(response.status).toBe(500)
168+
expect(response.status).toBe(400)
169169
expect(data.success).toBe(false)
170-
expect(data.message).toContain('Internal server error')
170+
expect(data.message).toContain('Invalid JSON payload')
171171
})
172172
})

0 commit comments

Comments
 (0)