@stacksjs/defaults 0.74.63 → 0.74.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "publisher": "Stacks",
3
3
  "name": "vscode-stacks",
4
4
  "displayName": "Stacks",
5
- "version": "0.74.63",
5
+ "version": "0.74.64",
6
6
  "description": "A modern Stacks development environment.",
7
7
  "license": "MIT",
8
8
  "funding": "https://github.com/sponsors/chrisbbreuer",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/defaults",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.74.63",
5
+ "version": "0.74.64",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/stacksjs/stacks.git",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "@iconify-json/f7": "^1.2.2",
57
57
  "@iconify-json/hugeicons": "^1.2.27",
58
- "@stacksjs/mobile": "^0.74.63",
58
+ "@stacksjs/mobile": "^0.74.64",
59
59
  "@stacksjs/sanitizer": "^0.2.113",
60
60
  "ts-qr-codes": "^0.1.8"
61
61
  }
@@ -2,7 +2,8 @@
2
2
  Form block: renders a @stacksjs/forms form inline on a CMS page.
3
3
  `props.formUuid` names the form; the client script fetches the public
4
4
  definition (same-origin), renders the fields, evaluates conditions live,
5
- and submits with the CSRF header the page response seeded.
5
+ and submits through useMutation, which repeats the page's CSRF cookie in
6
+ the header the submit route checks.
6
7
  --}}
7
8
  <div class="cms-form mx-auto max-w-xl px-6 py-8" data-form-uuid="{{ props.formUuid }}">
8
9
  <script client>
@@ -84,24 +85,24 @@
84
85
  payload.append('field', field.name)
85
86
  payload.append('file', file)
86
87
 
87
- const reply = await fetch(`/api/forms/${uuid}/uploads`, {
88
- method: 'POST',
89
- credentials: 'same-origin',
90
- body: payload,
91
- })
92
- const body = await reply.json().catch(() => ({}))
93
-
94
- if (!reply.ok) {
88
+ // Through the data layer rather than a bare fetch(): it is what echoes
89
+ // the CSRF cookie the submit routes require, and it passes FormData
90
+ // through untouched.
91
+ let body: { path?: string }
92
+ try {
93
+ body = await useMutation(`/api/forms/${uuid}/uploads`).mutate(payload)
94
+ }
95
+ catch (failure) {
95
96
  // Clear the input as well as the value: leaving a rejected file
96
97
  // showing next to its own error message reads as though it was taken.
97
98
  input.value = ''
98
99
  delete values[field.name]
99
100
  delete attached[field.name]
100
- errors.set({ ...errors(), [field.name]: body.message ?? 'That file could not be uploaded.' })
101
+ errors.set({ ...errors(), [field.name]: (failure as { data?: { message?: string } }).data?.message ?? 'That file could not be uploaded.' })
101
102
  return
102
103
  }
103
104
 
104
- values[field.name] = body.path
105
+ values[field.name] = body.path ?? ''
105
106
  attached[field.name] = file.name
106
107
  }
107
108
  catch {
@@ -144,20 +145,19 @@
144
145
 
145
146
  try {
146
147
  const uuid = host()?.dataset.formUuid
147
- const reply = await fetch(`/api/forms/${uuid}/submissions`, {
148
- method: 'POST',
149
- headers: { 'Content-Type': 'application/json' },
150
- credentials: 'same-origin',
151
- body: JSON.stringify({ ...values, _renderedMs: Date.now() - openedAt }),
152
- })
153
- const body = await reply.json().catch(() => ({}))
154
-
155
- if (reply.status === 422) {
156
- errors.set(body.errors ?? {})
157
- return
148
+ // useMutation, not fetch(): the submit route is CSRF-protected and the
149
+ // data layer is what repeats the cookie in the header. A bare fetch got
150
+ // 403 "CSRF token mismatch" on every submission.
151
+ let body: { message?: string, redirect?: string }
152
+ try {
153
+ body = await useMutation(`/api/forms/${uuid}/submissions`).mutate({ ...values, _renderedMs: Date.now() - openedAt })
158
154
  }
159
- if (!reply.ok) {
160
- errors.set({ _form: body.message ?? 'Something went wrong. Please try again.' })
155
+ catch (failure) {
156
+ const { status, data } = failure as { status?: number, data?: { errors?: FormErrors, message?: string } }
157
+ if (status === 422)
158
+ errors.set(data?.errors ?? {})
159
+ else
160
+ errors.set({ _form: data?.message ?? 'Something went wrong. Please try again.' })
161
161
  return
162
162
  }
163
163
  if (body.redirect) {
package/routes/forms.ts CHANGED
@@ -82,7 +82,7 @@ route.post('/api/forms/{uuid}/uploads', async (request: any) => {
82
82
  }).rateLimit(20, 'minute')
83
83
 
84
84
  route.post('/api/forms/{uuid}/submissions', async (request: any) => {
85
- const { dispatchSubmissionNotifications, loadFormByUuid, submitForm } = await import('@stacksjs/forms')
85
+ const { dispatchSubmissionNotifications, loadFormByUuid, submissionIdentity, submitForm } = await import('@stacksjs/forms')
86
86
 
87
87
  const form = await loadFormByUuid(String(request.params?.uuid ?? request.param?.('uuid') ?? ''), await siteIdForRequest(request))
88
88
  if (!form)
@@ -105,10 +105,14 @@ route.post('/api/forms/{uuid}/submissions', async (request: any) => {
105
105
 
106
106
  // Fire-and-forget AFTER the write: a slow mail transport must not hold a
107
107
  // parent's phone on a spinner, and a failed one must not undo the answers.
108
+ // The identity lookup belongs inside that chain too. It used to be awaited
109
+ // in the handler, so when it threw - and it always did, selecting a
110
+ // `values` column that has never existed - the visitor got a 500 for an
111
+ // answer that was already saved, and every retry saved another copy.
108
112
  if (result.submissionId > 0) {
109
- void dispatchSubmissionNotifications(form, result, {
110
- ...await submissionIdentity(result.submissionId),
111
- }).catch(() => {})
113
+ void submissionIdentity(result.submissionId)
114
+ .then(identity => dispatchSubmissionNotifications(form, result, identity))
115
+ .catch(() => {})
112
116
  }
113
117
 
114
118
  return response.json({
@@ -119,38 +123,3 @@ route.post('/api/forms/{uuid}/submissions', async (request: any) => {
119
123
  redirect: result.redirect,
120
124
  }, { status: 201 })
121
125
  }).rateLimit(10, 'minute')
122
-
123
- async function submissionIdentity(submissionId: number): Promise<{ email: string | null, name: string | null, values: Record<string, unknown> }> {
124
- const { db } = await import('@stacksjs/database')
125
- const row = await db
126
- .selectFrom('form_submissions')
127
- .where('id', '=', submissionId)
128
- .select(['email', 'name', 'values'])
129
- .executeTakeFirst() as { email: string | null, name: string | null, values: string | null } | undefined
130
-
131
- let values: Record<string, unknown> = {}
132
- try {
133
- values = row?.values ? JSON.parse(row.values) as Record<string, unknown> : {}
134
- }
135
- catch {
136
- // unreadable values only degrade the notification summary
137
- }
138
- return { email: row?.email ?? null, name: row?.name ?? null, values }
139
- }
140
-
141
- /** Admin CSV export. Auth'd; site scoping rides the form lookup. */
142
- route.get('/api/admin/forms/{uuid}/submissions.csv', async (request: any) => {
143
- const { exportSubmissionsCsv, loadFormByUuid } = await import('@stacksjs/forms')
144
-
145
- const form = await loadFormByUuid(String(request.params?.uuid ?? request.param?.('uuid') ?? ''), await siteIdForRequest(request))
146
- if (!form)
147
- return response.notFound('Form not found')
148
-
149
- const csv = await exportSubmissionsCsv(form)
150
- return new Response(csv, {
151
- headers: {
152
- 'Content-Type': 'text/csv; charset=utf-8',
153
- 'Content-Disposition': `attachment; filename="${form.handle}-submissions.csv"`,
154
- },
155
- })
156
- }).middleware('auth')