Skip to content

Commit 8b10ca2

Browse files
committed
Extend Astro's ActionError in our custom error for actions, refactor actions to use our handler
1 parent 3c18809 commit 8b10ca2

9 files changed

Lines changed: 261 additions & 178 deletions

File tree

src/actions/contact/action.ts

Lines changed: 84 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ActionError, defineAction } from 'astro:actions'
44
import { checkContactRateLimit } from '@actions/utils/rateLimit'
55
import { buildRequestFingerprint, createRateLimitIdentifier } from '@actions/utils/requestContext'
66
import { getPrivacyPolicyVersion, getResendApiKey, isProd } from '@actions/utils/environment/environmentActions'
7+
import { ActionsFunctionError, throwActionError } from '@actions/utils/errors'
78
import { createConsentRecord } from '@actions/gdpr/domain/consentStore'
89
import type { ContactFormData, FileAttachment, EmailData } from '@actions/contact/@types'
910
import { generateEmailContent, parseAttachments, validateInput } from './domain'
@@ -15,7 +16,6 @@ async function sendEmail(emailData: EmailData, files: FileAttachment[]): Promise
1516
}
1617

1718
const resend = new Resend(getResendApiKey())
18-
// @TODO: Resend has a size limit of 40 MB
1919
const attachments = files.map(file => ({ filename: file.filename, content: file.content }))
2020

2121
const result = await resend.emails.send({
@@ -27,97 +27,106 @@ async function sendEmail(emailData: EmailData, files: FileAttachment[]): Promise
2727
})
2828

2929
if (!result.data) {
30-
throw new ActionError({ code: 'BAD_GATEWAY', message: 'Failed to send email. Please try again later.' })
30+
throw new ActionsFunctionError('Failed to send email. Please try again later.', { status: 502 })
3131
}
3232
}
3333

3434
export const contact = {
3535
submit: defineAction({
3636
accept: 'form',
3737
handler: async (form: FormData, context): Promise<{ success: true; message: string }> => {
38-
const { fingerprint } = buildRequestFingerprint({
39-
route: '/_actions/contact/submit',
40-
request: context.request,
41-
cookies: context.cookies,
42-
clientAddress: context.clientAddress,
43-
})
44-
45-
const rateLimitIdentifier = createRateLimitIdentifier('contact', fingerprint)
46-
if (!checkContactRateLimit(rateLimitIdentifier)) {
47-
throw new ActionError({
48-
code: 'TOO_MANY_REQUESTS',
49-
message: 'Too many form submissions. Please try again later.',
38+
const route = '/_actions/contact/submit'
39+
40+
try {
41+
const { fingerprint } = buildRequestFingerprint({
42+
route,
43+
request: context.request,
44+
cookies: context.cookies,
45+
clientAddress: context.clientAddress,
5046
})
51-
}
5247

53-
const formData: ContactFormData = {
54-
name: readString(form, 'name'),
55-
email: readString(form, 'email'),
56-
message: readString(form, 'message'),
57-
consent: parseBoolean(form.get('consent')),
58-
}
48+
const rateLimitIdentifier = createRateLimitIdentifier('contact', fingerprint)
49+
if (!checkContactRateLimit(rateLimitIdentifier)) {
50+
throw new ActionsFunctionError('Too many form submissions. Please try again later.', { status: 429 })
51+
}
5952

60-
const phone = readString(form, 'phone')
61-
const service = readString(form, 'service')
62-
const budget = readString(form, 'budget')
63-
const timeline = readString(form, 'timeline')
64-
const website = readString(form, 'website')
53+
const formData: ContactFormData = {
54+
name: readString(form, 'name'),
55+
email: readString(form, 'email'),
56+
message: readString(form, 'message'),
57+
consent: parseBoolean(form.get('consent')),
58+
}
6559

66-
if (phone) formData.phone = phone
67-
if (service) formData.service = service
68-
if (budget) formData.budget = budget
69-
if (timeline) formData.timeline = timeline
70-
if (website) formData.website = website
60+
const phone = readString(form, 'phone')
61+
const service = readString(form, 'service')
62+
const budget = readString(form, 'budget')
63+
const timeline = readString(form, 'timeline')
64+
const website = readString(form, 'website')
7165

72-
const files = await parseAttachments(form)
66+
if (phone) formData.phone = phone
67+
if (service) formData.service = service
68+
if (budget) formData.budget = budget
69+
if (timeline) formData.timeline = timeline
70+
if (website) formData.website = website
7371

74-
const validationErrors = validateInput(formData)
75-
if (validationErrors.length > 0) {
76-
throw new ActionError({ code: 'BAD_REQUEST', message: validationErrors[0] ?? 'Invalid form submission' })
77-
}
72+
const files = await parseAttachments(form)
7873

79-
const userAgent = context.request.headers.get('user-agent') || 'unknown'
80-
const ip =
81-
context.clientAddress ||
82-
context.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
83-
context.request.headers.get('x-real-ip') ||
84-
'unknown'
85-
86-
if (formData.consent) {
87-
let subjectId = formData.DataSubjectId
88-
if (!subjectId) {
89-
subjectId = uuidv4()
90-
} else if (!uuidValidate(subjectId)) {
91-
throw new ActionError({ code: 'BAD_REQUEST', message: 'Invalid DataSubjectId format' })
74+
const validationErrors = validateInput(formData)
75+
if (validationErrors.length > 0) {
76+
throw new ActionsFunctionError(validationErrors[0] ?? 'Invalid form submission', { status: 400 })
9277
}
9378

94-
await createConsentRecord({
95-
dataSubjectId: subjectId,
96-
email: formData.email.trim(),
97-
purposes: ['contact'],
98-
source: 'contact_form',
99-
userAgent,
100-
ipAddress: ip !== 'unknown' ? ip : null,
101-
privacyPolicyVersion: getPrivacyPolicyVersion(),
102-
consentText: null,
103-
verified: true,
104-
})
105-
}
79+
const userAgent = context.request.headers.get('user-agent') || 'unknown'
80+
const ip =
81+
context.clientAddress ||
82+
context.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
83+
context.request.headers.get('x-real-ip') ||
84+
'unknown'
85+
86+
if (formData.consent) {
87+
let subjectId = formData.DataSubjectId
88+
if (!subjectId) {
89+
subjectId = uuidv4()
90+
} else if (!uuidValidate(subjectId)) {
91+
throw new ActionsFunctionError('Invalid DataSubjectId format', { status: 400 })
92+
}
93+
94+
await createConsentRecord({
95+
dataSubjectId: subjectId,
96+
email: formData.email.trim(),
97+
purposes: ['contact'],
98+
source: 'contact_form',
99+
userAgent,
100+
ipAddress: ip !== 'unknown' ? ip : null,
101+
privacyPolicyVersion: getPrivacyPolicyVersion(),
102+
consentText: null,
103+
verified: true,
104+
})
105+
}
106106

107-
const htmlContent = generateEmailContent(formData, files)
108-
await sendEmail(
109-
{
110-
from: 'contact@webstackbuilders.com',
111-
to: 'info@webstackbuilders.com',
112-
subject: `Contact Form: ${formData.name}`,
113-
html: htmlContent,
114-
},
115-
files,
116-
)
117-
118-
return {
119-
success: true,
120-
message: 'Thank you for your message. We will get back to you soon!',
107+
const htmlContent = generateEmailContent(formData, files)
108+
await sendEmail(
109+
{
110+
from: 'contact@webstackbuilders.com',
111+
to: 'info@webstackbuilders.com',
112+
subject: `Contact Form: ${formData.name}`,
113+
html: htmlContent,
114+
},
115+
files,
116+
)
117+
118+
return {
119+
success: true,
120+
message: 'Thank you for your message. We will get back to you soon!',
121+
}
122+
} catch (error) {
123+
if (error instanceof ActionsFunctionError) {
124+
throw error
125+
}
126+
if (error instanceof ActionError) {
127+
throw error
128+
}
129+
throwActionError(error, { route, operation: 'submit' })
121130
}
122131
},
123132
}),

src/actions/contact/domain.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { Buffer } from 'node:buffer'
22
import emailValidator from 'email-validator'
3-
import { ActionError } from 'astro:actions'
3+
import { ActionsFunctionError } from '@actions/utils/errors'
44
import { escapeHtml, formatFileSize } from './utils'
55
import type { ContactFormData, FileAttachment } from '@actions/contact/@types'
66

7-
export function generateEmailContent(data: ContactFormData, files: FileAttachment[]): string {
7+
export function generateEmailContent(data: ContactFormData, files: FileAttachment[]): string {
88
const fields = [
99
`<p><strong>Name:</strong> ${escapeHtml(data.name)}</p>`,
1010
`<p><strong>Email:</strong> ${escapeHtml(data.email)}</p>`,
@@ -68,15 +68,15 @@ export async function parseAttachments(form: FormData): Promise<FileAttachment[]
6868
fileCount++
6969

7070
if (fileCount > maxFiles) {
71-
throw new ActionError({ code: 'BAD_REQUEST', message: `Maximum ${maxFiles} files allowed` })
71+
throw new ActionsFunctionError(`Maximum ${maxFiles} files allowed`, { status: 400 })
7272
}
7373

7474
if (value.size > maxFileSize) {
75-
throw new ActionError({ code: 'BAD_REQUEST', message: `File ${value.name} exceeds 10MB limit` })
75+
throw new ActionsFunctionError(`File ${value.name} exceeds 10MB limit`, { status: 400 })
7676
}
7777

7878
if (!allowedTypes.includes(value.type)) {
79-
throw new ActionError({ code: 'BAD_REQUEST', message: `File type ${value.type} not allowed` })
79+
throw new ActionsFunctionError(`File type ${value.type} not allowed`, { status: 400 })
8080
}
8181

8282
const buffer = Buffer.from(await value.arrayBuffer())

src/actions/gdpr/responder.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import emailValidator from 'email-validator'
22
import { validate as uuidValidate } from 'uuid'
3-
import { ActionError, defineAction } from 'astro:actions'
3+
import { defineAction } from 'astro:actions'
44
import { z } from 'astro:schema'
55
import { getPrivacyPolicyVersion } from '@actions/utils/environment/environmentActions'
66
import { checkRateLimit, rateLimiters } from '@actions/utils/rateLimit'
77
import { buildRequestFingerprint, createRateLimitIdentifier } from '@actions/utils/requestContext'
8+
import { ActionsFunctionError, handleActionsFunctionError } from '@actions/utils/errors'
89
import type {
910
ConsentRequest,
1011
ConsentResponse,
@@ -60,10 +61,7 @@ const normalizeUserAgent = (value?: string | null): string => normalizeNullableS
6061
const buildRateLimitError = (reset: number | undefined, message?: string) => {
6162
const retryAfterMs = typeof reset === 'number' ? Math.max(0, reset - Date.now()) : 0
6263
const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000))
63-
throw new ActionError({
64-
code: 'TOO_MANY_REQUESTS',
65-
message: message ?? `Try again in ${retryAfterSeconds}s`,
66-
})
64+
throw new ActionsFunctionError(message ?? `Try again in ${retryAfterSeconds}s`, { status: 429 })
6765
}
6866

6967
const mapConsentRecord = (record: ConsentEventRecord): ConsentResponse['record'] => {
@@ -193,7 +191,7 @@ export const gdpr = {
193191
try {
194192
return await verifyDsarToken(input.token)
195193
} catch (error) {
196-
console.error('[gdpr.verifyDsar] failed:', error)
194+
handleActionsFunctionError(error, { route: '/_actions/gdpr/verifyDsar', operation: 'verifyDsar' })
197195
return { status: 'error' }
198196
}
199197
},
@@ -217,7 +215,7 @@ export const gdpr = {
217215
}
218216

219217
if (!uuidValidate(body.DataSubjectId)) {
220-
throw new ActionError({ code: 'BAD_REQUEST', message: 'Invalid DataSubjectId' })
218+
throw new ActionsFunctionError('Invalid DataSubjectId', { status: 400 })
221219
}
222220

223221
const normalizedEmail = normalizeNullableString(body.email ?? null)
@@ -266,7 +264,7 @@ export const gdpr = {
266264
const { DataSubjectId, purpose } = input
267265

268266
if (!DataSubjectId || !uuidValidate(DataSubjectId)) {
269-
throw new ActionError({ code: 'BAD_REQUEST', message: 'Valid DataSubjectId required' })
267+
throw new ActionsFunctionError('Valid DataSubjectId required', { status: 400 })
270268
}
271269

272270
const fetched = await findConsentRecords(DataSubjectId)
@@ -312,7 +310,7 @@ export const gdpr = {
312310
}
313311

314312
if (!uuidValidate(input.DataSubjectId)) {
315-
throw new ActionError({ code: 'BAD_REQUEST', message: 'Valid DataSubjectId required' })
313+
throw new ActionsFunctionError('Valid DataSubjectId required', { status: 400 })
316314
}
317315

318316
const deletedCount = await deleteConsentRecords(input.DataSubjectId)
@@ -338,7 +336,7 @@ export const gdpr = {
338336
}
339337

340338
if (!emailValidator.validate(input.email)) {
341-
throw new ActionError({ code: 'BAD_REQUEST', message: 'Invalid email format' })
339+
throw new ActionsFunctionError('Invalid email format', { status: 400 })
342340
}
343341

344342
const email = input.email.toLowerCase().trim()
@@ -390,7 +388,7 @@ export const gdpr = {
390388
}
391389

392390
if (!uuidValidate(input.DataSubjectId)) {
393-
throw new ActionError({ code: 'BAD_REQUEST', message: 'Invalid DataSubjectId' })
391+
throw new ActionsFunctionError('Invalid DataSubjectId', { status: 400 })
394392
}
395393

396394
const consentRecords = await findConsentRecords(input.DataSubjectId)

src/actions/newsletter/entities.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ export async function sendConfirmationEmail(email: string, token: string, firstN
199199
console.error('[Newsletter Email] Failed to send confirmation:', result.error)
200200
throw new ActionsFunctionError({
201201
message: `Failed to send confirmation email: ${result.error.message}`,
202-
code: 'NEWSLETTER_CONFIRMATION_EMAIL_FAILED',
202+
appCode: 'NEWSLETTER_CONFIRMATION_EMAIL_FAILED',
203203
status: 502,
204204
route: 'actions:newsletter',
205205
operation: 'sendConfirmationEmail',
@@ -209,7 +209,7 @@ export async function sendConfirmationEmail(email: string, token: string, firstN
209209
console.error('[Newsletter Email] Error sending confirmation:', error)
210210
throw new ActionsFunctionError(error, {
211211
message: 'Failed to send confirmation email. Please try again later.',
212-
code: 'NEWSLETTER_CONFIRMATION_EMAIL_FAILED',
212+
appCode: 'NEWSLETTER_CONFIRMATION_EMAIL_FAILED',
213213
status: 502,
214214
route: 'actions:newsletter',
215215
operation: 'sendConfirmationEmail',
@@ -361,7 +361,7 @@ Questions? Reply to this email or contact us at hello@webstackbuilders.com
361361
console.error('[Newsletter Email] Failed to send welcome email:', result.error)
362362
throw new ActionsFunctionError({
363363
message: `Failed to send welcome email: ${result.error.message}`,
364-
code: 'NEWSLETTER_WELCOME_EMAIL_FAILED',
364+
appCode: 'NEWSLETTER_WELCOME_EMAIL_FAILED',
365365
status: 502,
366366
route: 'actions:newsletter',
367367
operation: 'sendWelcomeEmail',
@@ -371,7 +371,7 @@ Questions? Reply to this email or contact us at hello@webstackbuilders.com
371371
console.error('[Newsletter Email] Error sending welcome email:', error)
372372
throw new ActionsFunctionError(error, {
373373
message: 'Failed to send welcome email. Please try again later.',
374-
code: 'NEWSLETTER_WELCOME_EMAIL_FAILED',
374+
appCode: 'NEWSLETTER_WELCOME_EMAIL_FAILED',
375375
status: 502,
376376
route: 'actions:newsletter',
377377
operation: 'sendWelcomeEmail',

0 commit comments

Comments
 (0)