create-nextblock 0.11.2 → 0.12.0
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.
- package/package.json +1 -1
- package/templates/nextblock-template/app/(auth-pages)/sign-up/SignUpForm.tsx +141 -0
- package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +40 -122
- package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx +5 -0
- package/templates/nextblock-template/app/[slug]/page.tsx +5 -2
- package/templates/nextblock-template/app/[slug]/page.utils.ts +10 -9
- package/templates/nextblock-template/app/actions/email.ts +8 -1
- package/templates/nextblock-template/app/actions/feedback.ts +1 -0
- package/templates/nextblock-template/app/actions/formActions.ts +20 -97
- package/templates/nextblock-template/app/actions/interactions.ts +3 -2
- package/templates/nextblock-template/app/actions/twoFactorEmail.ts +3 -2
- package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +1 -1
- package/templates/nextblock-template/app/actions/visualEditingActions.ts +2 -0
- package/templates/nextblock-template/app/actions.ts +14 -0
- package/templates/nextblock-template/app/api/brand/email-logo/route.ts +48 -0
- package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +47 -1
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +4970 -8566
- package/templates/nextblock-template/app/article/[slug]/page.tsx +5 -2
- package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -0
- package/templates/nextblock-template/app/cms/pages/actions.ts +22 -18
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +20 -2
- package/templates/nextblock-template/app/cms/posts/actions.ts +5 -0
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +137 -133
- package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +36 -0
- package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +26 -1
- package/templates/nextblock-template/app/cms/settings/copyright/page.tsx +6 -2
- package/templates/nextblock-template/app/cms/settings/email/actions.ts +7 -3
- package/templates/nextblock-template/app/cms/settings/logos/actions.ts +29 -14
- package/templates/nextblock-template/app/cms/settings/logos/components/SetActiveLogoButton.tsx +42 -0
- package/templates/nextblock-template/app/cms/settings/logos/page.tsx +18 -5
- package/templates/nextblock-template/app/layout.tsx +29 -17
- package/templates/nextblock-template/app/lib/seo.test.ts +36 -0
- package/templates/nextblock-template/app/lib/seo.ts +32 -0
- package/templates/nextblock-template/app/product/[slug]/page.tsx +5 -2
- package/templates/nextblock-template/components/AppShell.tsx +16 -1
- package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -0
- package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +36 -42
- package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +7 -0
- package/templates/nextblock-template/lib/botProtection/verify.ts +134 -0
- package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -0
- package/templates/nextblock-template/lib/email/branding-format.ts +123 -0
- package/templates/nextblock-template/lib/email/branding.ts +76 -0
- package/templates/nextblock-template/lib/logos/active-logo.ts +53 -0
- package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +1 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +8 -203
- package/templates/nextblock-template/lib/visual-editing/draft-content.ts +4 -2
- package/templates/nextblock-template/lib/visual-editing/mutations.ts +2 -2
- package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +1 -0
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/proxy.ts +16 -0
- package/templates/nextblock-template/public/images/nextblock-logo-button-tiny.png +0 -0
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import Script from 'next/script';
|
|
5
|
+
|
|
6
|
+
// Field names must match the server verifier in lib/botProtection/verify.ts.
|
|
7
|
+
const HONEYPOT_FIELD = 'verification_secondary_email';
|
|
8
|
+
const TURNSTILE_TOKEN_FIELD = 'cf-turnstile-response';
|
|
9
|
+
const RECAPTCHA_TOKEN_FIELD = 'g-recaptcha-response';
|
|
10
|
+
|
|
11
|
+
type BotProtectionProvider = 'none' | 'turnstile' | 'recaptcha';
|
|
12
|
+
|
|
13
|
+
interface AuthBotProtectionProps {
|
|
14
|
+
provider: BotProtectionProvider;
|
|
15
|
+
siteKey: string;
|
|
16
|
+
scriptNonce?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Drop-in bot protection for auth forms. Renders inside a <form> and contributes:
|
|
21
|
+
* - an always-on honeypot field (catches naive bots with zero UX cost), and
|
|
22
|
+
* - the site-configured Turnstile / reCAPTCHA widget, writing its token into a
|
|
23
|
+
* hidden input so it submits with the surrounding form.
|
|
24
|
+
*
|
|
25
|
+
* It never intercepts submit: auth actions redirect on every outcome, so a failed
|
|
26
|
+
* attempt reloads the page and remounts a fresh widget — no stale single-use tokens.
|
|
27
|
+
*/
|
|
28
|
+
export function AuthBotProtection({ provider, siteKey, scriptNonce }: AuthBotProtectionProps) {
|
|
29
|
+
const turnstileRef = useRef<HTMLDivElement>(null);
|
|
30
|
+
const turnstileWidgetIdRef = useRef<string | null>(null);
|
|
31
|
+
const [turnstileToken, setTurnstileToken] = useState('');
|
|
32
|
+
const [recaptchaToken, setRecaptchaToken] = useState('');
|
|
33
|
+
|
|
34
|
+
const showTurnstile = provider === 'turnstile' && !!siteKey;
|
|
35
|
+
const showRecaptcha = provider === 'recaptcha' && !!siteKey;
|
|
36
|
+
|
|
37
|
+
// Turnstile: explicit managed render. The callback fires once the challenge is
|
|
38
|
+
// solved (usually automatically) and we stash the token in a hidden input.
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!showTurnstile || typeof window === 'undefined') return;
|
|
41
|
+
|
|
42
|
+
let widgetId: string | null = null;
|
|
43
|
+
|
|
44
|
+
const renderWidget = () => {
|
|
45
|
+
const turnstile = (window as any).turnstile;
|
|
46
|
+
if (!turnstile || !turnstileRef.current) return;
|
|
47
|
+
turnstileRef.current.innerHTML = '';
|
|
48
|
+
try {
|
|
49
|
+
widgetId = turnstile.render(turnstileRef.current, {
|
|
50
|
+
sitekey: siteKey,
|
|
51
|
+
theme: 'auto',
|
|
52
|
+
'response-field': false,
|
|
53
|
+
callback: (token: string) => setTurnstileToken(token),
|
|
54
|
+
'expired-callback': () => setTurnstileToken(''),
|
|
55
|
+
'error-callback': () => setTurnstileToken(''),
|
|
56
|
+
'timeout-callback': () => setTurnstileToken(''),
|
|
57
|
+
});
|
|
58
|
+
turnstileWidgetIdRef.current = widgetId;
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.error('Turnstile render error:', err);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if ((window as any).turnstile) {
|
|
65
|
+
renderWidget();
|
|
66
|
+
} else {
|
|
67
|
+
const interval = setInterval(() => {
|
|
68
|
+
if ((window as any).turnstile) {
|
|
69
|
+
clearInterval(interval);
|
|
70
|
+
renderWidget();
|
|
71
|
+
}
|
|
72
|
+
}, 100);
|
|
73
|
+
return () => clearInterval(interval);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return () => {
|
|
77
|
+
const turnstile = (window as any).turnstile;
|
|
78
|
+
if (widgetId && turnstile) {
|
|
79
|
+
try {
|
|
80
|
+
turnstile.remove(widgetId);
|
|
81
|
+
} catch {
|
|
82
|
+
// ignore
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
turnstileWidgetIdRef.current = null;
|
|
86
|
+
};
|
|
87
|
+
}, [showTurnstile, siteKey]);
|
|
88
|
+
|
|
89
|
+
// reCAPTCHA v3: invisible + score-based. Fetch a token on load and refresh it
|
|
90
|
+
// before the ~2min expiry so a token is always waiting when the user submits.
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!showRecaptcha || typeof window === 'undefined') return;
|
|
93
|
+
|
|
94
|
+
let cancelled = false;
|
|
95
|
+
|
|
96
|
+
const execute = () => {
|
|
97
|
+
const grecaptcha = (window as any).grecaptcha;
|
|
98
|
+
if (!grecaptcha?.execute) return;
|
|
99
|
+
grecaptcha.ready(() => {
|
|
100
|
+
grecaptcha
|
|
101
|
+
.execute(siteKey, { action: 'signup' })
|
|
102
|
+
.then((token: string) => {
|
|
103
|
+
if (!cancelled) setRecaptchaToken(token);
|
|
104
|
+
})
|
|
105
|
+
.catch(() => {
|
|
106
|
+
/* transient; the next refresh retries */
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
let readyPoll: ReturnType<typeof setInterval> | null = null;
|
|
112
|
+
if ((window as any).grecaptcha?.execute) {
|
|
113
|
+
execute();
|
|
114
|
+
} else {
|
|
115
|
+
readyPoll = setInterval(() => {
|
|
116
|
+
if ((window as any).grecaptcha?.execute) {
|
|
117
|
+
if (readyPoll) clearInterval(readyPoll);
|
|
118
|
+
readyPoll = null;
|
|
119
|
+
execute();
|
|
120
|
+
}
|
|
121
|
+
}, 200);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const refresh = setInterval(execute, 100_000);
|
|
125
|
+
|
|
126
|
+
return () => {
|
|
127
|
+
cancelled = true;
|
|
128
|
+
if (readyPoll) clearInterval(readyPoll);
|
|
129
|
+
clearInterval(refresh);
|
|
130
|
+
};
|
|
131
|
+
}, [showRecaptcha, siteKey]);
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<>
|
|
135
|
+
{showRecaptcha && (
|
|
136
|
+
<Script
|
|
137
|
+
strategy="lazyOnload"
|
|
138
|
+
src={`https://www.google.com/recaptcha/api.js?render=${siteKey}`}
|
|
139
|
+
nonce={scriptNonce}
|
|
140
|
+
/>
|
|
141
|
+
)}
|
|
142
|
+
{showTurnstile && (
|
|
143
|
+
<Script
|
|
144
|
+
strategy="afterInteractive"
|
|
145
|
+
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
|
|
146
|
+
nonce={scriptNonce}
|
|
147
|
+
/>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
{/* Invisible honeypot: a real user never sees or fills this; bots do. */}
|
|
151
|
+
<div
|
|
152
|
+
aria-hidden="true"
|
|
153
|
+
className="absolute w-0 h-0 overflow-hidden"
|
|
154
|
+
style={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden', opacity: 0, zIndex: -1 }}
|
|
155
|
+
>
|
|
156
|
+
<label htmlFor={HONEYPOT_FIELD} className="sr-only">
|
|
157
|
+
Do not fill this field
|
|
158
|
+
</label>
|
|
159
|
+
<input
|
|
160
|
+
id={HONEYPOT_FIELD}
|
|
161
|
+
type="text"
|
|
162
|
+
name={HONEYPOT_FIELD}
|
|
163
|
+
tabIndex={-1}
|
|
164
|
+
autoComplete="off"
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
{showTurnstile && (
|
|
169
|
+
<div className="my-2 flex justify-start">
|
|
170
|
+
<input type="hidden" name={TURNSTILE_TOKEN_FIELD} value={turnstileToken} readOnly />
|
|
171
|
+
<div ref={turnstileRef} />
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
|
|
175
|
+
{showRecaptcha && (
|
|
176
|
+
<input type="hidden" name={RECAPTCHA_TOKEN_FIELD} value={recaptchaToken} readOnly />
|
|
177
|
+
)}
|
|
178
|
+
</>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export default AuthBotProtection;
|
|
@@ -161,19 +161,26 @@ Commerce-specific policy highlights include:
|
|
|
161
161
|
|
|
162
162
|
### Current reality
|
|
163
163
|
|
|
164
|
-
The
|
|
165
|
-
`
|
|
166
|
-
|
|
167
|
-
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
164
|
+
The folder was **re-baselined in 2026-07**: the previous 45 migrations
|
|
165
|
+
(`00000000000000`–`00000000000044`) were squashed into a four-file idempotent
|
|
166
|
+
baseline, generated from a fresh-apply `pg_dump` by
|
|
167
|
+
`tools/scripts/rebaseline-transform.mjs` and verified byte-identical to the old
|
|
168
|
+
tree. The current sequence is:
|
|
169
|
+
|
|
170
|
+
- `00000000000000_baseline_schema.sql` — enums, functions, tables, sequences
|
|
171
|
+
(all `IF NOT EXISTS` / `CREATE OR REPLACE`) plus the re-attached `auth.users`
|
|
172
|
+
→ `handle_new_user` trigger.
|
|
173
|
+
- `00000000000001_baseline_constraints_and_indexes.sql` — primary/unique/check
|
|
174
|
+
and foreign-key constraints (guarded) plus all indexes.
|
|
175
|
+
- `00000000000002_baseline_security_and_grants.sql` — RLS enablement, policies
|
|
176
|
+
(`DROP … IF EXISTS` first), triggers, and grants.
|
|
177
|
+
- `00000000000003_baseline_seed.sql` — canonical demo content, `ON CONFLICT DO
|
|
178
|
+
NOTHING` (no users, no secrets).
|
|
179
|
+
|
|
180
|
+
Every file is fully idempotent. Existing databases already have versions
|
|
181
|
+
`000`–`003` recorded, so both appliers skip the baseline — it only runs on a
|
|
182
|
+
fresh/empty database. **The next new migration is `00000000000004`**, appended
|
|
183
|
+
forward-only.
|
|
177
184
|
|
|
178
185
|
### Production migration policy
|
|
179
186
|
|
|
@@ -188,41 +195,28 @@ production or shared database change.
|
|
|
188
195
|
that may include orders, users, payments, or customer records.
|
|
189
196
|
- Run `npm run db:migrate:check` before `npm run db:migrate`.
|
|
190
197
|
- If an existing database lists old baseline files such as
|
|
191
|
-
`
|
|
192
|
-
|
|
193
|
-
`npm run db:migrate:repair-history
|
|
194
|
-
`
|
|
198
|
+
`00000000000000_baseline_schema.sql` as pending, do not replay them. Use
|
|
199
|
+
`npm run db:migrate:repair-history:check`, then
|
|
200
|
+
`npm run db:migrate:repair-history --through=00000000000003` (the baseline's
|
|
201
|
+
top file creates no tables, so auto-detection otherwise stops at `000`), then
|
|
202
|
+
rerun `npm run db:migrate:check`.
|
|
195
203
|
- Use `npm run db:migrate:fresh` only for a brand-new empty database.
|
|
196
204
|
|
|
197
205
|
### Category map
|
|
198
206
|
|
|
199
207
|
| Migration file | Domain | What it covers |
|
|
200
208
|
| :-- | :-- | :-- |
|
|
201
|
-
| `
|
|
202
|
-
| `
|
|
203
|
-
| `
|
|
204
|
-
| `
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
| `00000000000011_setup_cortex_ai_settings.sql` | AI, Settings | Cortex AI settings and provider defaults |
|
|
213
|
-
| `00000000000012_setup_commerce_coupons.sql` | Commerce | coupon tables and related commerce constraints |
|
|
214
|
-
| `00000000000013_setup_cortex_ai_db_mutation_audit.sql` | AI, Audit | Cortex AI database mutation audit support |
|
|
215
|
-
| `00000000000014_setup_content_drafts.sql` | CMS, Editor | visual-editing content draft tables |
|
|
216
|
-
| `00000000000015_setup_product_drafts.sql` | Commerce, Editor | product draft workflow support |
|
|
217
|
-
| `00000000000016_add_feature_image_to_pages.sql` | CMS | optional page feature image media relationship |
|
|
218
|
-
| `00000000000017_add_product_blocks.sql` | Commerce, Editor | block-based product descriptions (`blocks` JSONB column and `product_id` link) |
|
|
219
|
-
| `00000000000018_setup_bot_protection_settings.sql` | CMS, Security | Turnstile/reCAPTCHA bot-protection settings for forms; sensitive site-settings key protection |
|
|
220
|
-
| `00000000000019_add_product_categories.sql` | Commerce | `categories` and `product_categories` junction tables |
|
|
221
|
-
| `00000000000020_add_category_translations.sql` | Commerce, i18n | `name_translations` / `description_translations` on categories |
|
|
222
|
-
| `00000000000021_migrate_hero_blocks_to_sections.sql` | CMS, Editor | data migration converting legacy `hero` blocks into `section` blocks (`is_hero`) |
|
|
223
|
-
| `00000000000022_seed_cortex_ai_guide_post.sql` | Seeds, AI | seeds the Cortex AI guide post |
|
|
224
|
-
| `00000000000023_setup_custom_block_definitions.sql` | CMS, Editor | `custom_block_definitions` registry, validation functions, `duplicate_block_definition` RPC, and RLS (see [10-CUSTOM-BLOCKS.md](./10-CUSTOM-BLOCKS.md)) |
|
|
225
|
-
| `00000000000024_setup_ucp_cart_sessions.sql` | Commerce | `ucp_cart_sessions` table and update trigger for persisted carts |
|
|
209
|
+
| `00000000000000_baseline_schema.sql` | Core, CMS, Commerce | all enums, 40 functions, 49 tables + sequences (idempotent), and the `auth.users` → `handle_new_user` bootstrap trigger |
|
|
210
|
+
| `00000000000001_baseline_constraints_and_indexes.sql` | Core, CMS, Commerce | all primary/unique/check + foreign-key constraints (guarded) and every index |
|
|
211
|
+
| `00000000000002_baseline_security_and_grants.sql` | Security | RLS enablement on every table, all policies, timestamp/business triggers, grants |
|
|
212
|
+
| `00000000000003_baseline_seed.sql` | Seeds | canonical demo content — languages, currencies, site settings, translations, media, pages/posts/blocks, navigation, shipping defaults — all `ON CONFLICT DO NOTHING` |
|
|
213
|
+
|
|
214
|
+
The pre-2026-07 history (foundation/enums, cms_core, content_tables, catalog,
|
|
215
|
+
fulfillment, functions_and_triggers, rls_and_grants, indexes, the seed files, and
|
|
216
|
+
later additions like custom block definitions, product blocks, categories, cart
|
|
217
|
+
sessions, drafts, privacy/MFA, system alerts, interactions) is all folded into the
|
|
218
|
+
four files above; the earlier per-file boundaries survive only as comment headers
|
|
219
|
+
inside the generated SQL.
|
|
226
220
|
|
|
227
221
|
### How to read the folder
|
|
228
222
|
|
|
@@ -89,6 +89,13 @@ Variables, or your `.env`):
|
|
|
89
89
|
With that set, the dashboard conflict banner works on private forks too. (The workflow
|
|
90
90
|
itself still needs no extra secret — `GITHUB_TOKEN` covers it either way.)
|
|
91
91
|
|
|
92
|
+
> **⚠️ Vercel Hobby (free) plan + a private repo blocks auto-deploys.** On Hobby, Vercel only
|
|
93
|
+
> deploys **private**-repo commits authored by the project owner and rejects automated
|
|
94
|
+
> (bot/collaborator) commits — so the auto-merge push won't deploy (*"Hobby Plan does not
|
|
95
|
+
> support collaboration for private repositories"*). Either **make the repo public**
|
|
96
|
+
> (recommended — it also makes the conflict banner tokenless) or upgrade to **Vercel Pro**.
|
|
97
|
+
> Public repos have no such restriction on Hobby.
|
|
98
|
+
|
|
92
99
|
### How the dashboard stays current (no cron)
|
|
93
100
|
|
|
94
101
|
The CMS refreshes update/conflict status **in the background after a dashboard page
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// lib/botProtection/verify.ts
|
|
2
|
+
//
|
|
3
|
+
// Shared server-side bot-protection verification: the honeypot check plus
|
|
4
|
+
// Cloudflare Turnstile / Google reCAPTCHA token verification. This is the single
|
|
5
|
+
// source of truth used by BOTH the page contact-form handler
|
|
6
|
+
// (app/actions/formActions.ts) and the account-signup action (app/actions.ts).
|
|
7
|
+
//
|
|
8
|
+
// Server-only: it reads the RLS-bypassing service-role client, which throws if
|
|
9
|
+
// imported into a Client Component.
|
|
10
|
+
|
|
11
|
+
import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
|
|
12
|
+
|
|
13
|
+
export type BotProtectionProvider = 'none' | 'turnstile' | 'recaptcha';
|
|
14
|
+
|
|
15
|
+
// Shared field names — the client widgets emit these, the verifier reads them.
|
|
16
|
+
export const HONEYPOT_FIELD = 'verification_secondary_email';
|
|
17
|
+
export const TURNSTILE_TOKEN_FIELD = 'cf-turnstile-response';
|
|
18
|
+
export const RECAPTCHA_TOKEN_FIELD = 'g-recaptcha-response';
|
|
19
|
+
|
|
20
|
+
export type BotProtectionResult =
|
|
21
|
+
// Passed (or nothing configured beyond the honeypot).
|
|
22
|
+
| { ok: true }
|
|
23
|
+
// The honeypot was filled — almost certainly a bot. Callers should silently
|
|
24
|
+
// discard the submission and fake a success so the bot learns nothing.
|
|
25
|
+
| { ok: false; reason: 'honeypot' }
|
|
26
|
+
// The captcha was missing, failed, or could not be checked. `message` is a
|
|
27
|
+
// human-readable explanation safe to surface to the user.
|
|
28
|
+
| { ok: false; reason: 'captcha'; message: string };
|
|
29
|
+
|
|
30
|
+
type VerifyOptions = {
|
|
31
|
+
// A block/form may pin a specific provider; otherwise the site-wide setting
|
|
32
|
+
// (site_settings.bot_protection_public.provider) is used.
|
|
33
|
+
botProtectionProvider?: BotProtectionProvider;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Verify a submitted FormData against the configured bot protection.
|
|
38
|
+
*
|
|
39
|
+
* Phase 1 (honeypot) needs no network/DB and always runs — it is the always-on
|
|
40
|
+
* baseline. Phase 2 (captcha) reads the global provider + secret from
|
|
41
|
+
* `site_settings` and calls the provider's siteverify endpoint.
|
|
42
|
+
*/
|
|
43
|
+
export async function verifyBotProtection(
|
|
44
|
+
formData: FormData,
|
|
45
|
+
options?: VerifyOptions
|
|
46
|
+
): Promise<BotProtectionResult> {
|
|
47
|
+
// Phase 1: Honeypot validation
|
|
48
|
+
const honeypot = formData.get(HONEYPOT_FIELD);
|
|
49
|
+
if (honeypot && typeof honeypot === 'string' && honeypot.length > 0) {
|
|
50
|
+
console.warn('[Bot Protection] Honeypot triggered. Discarding submission from bot.');
|
|
51
|
+
return { ok: false, reason: 'honeypot' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Phase 2: Advanced captcha verification
|
|
55
|
+
try {
|
|
56
|
+
const supabase = getServiceRoleSupabaseClient();
|
|
57
|
+
|
|
58
|
+
const { data: publicSetting } = await supabase
|
|
59
|
+
.from('site_settings')
|
|
60
|
+
.select('value')
|
|
61
|
+
.eq('key', 'bot_protection_public')
|
|
62
|
+
.maybeSingle();
|
|
63
|
+
|
|
64
|
+
const { data: secretSetting } = await supabase
|
|
65
|
+
.from('site_settings')
|
|
66
|
+
.select('value')
|
|
67
|
+
.eq('key', 'bot_protection_secret')
|
|
68
|
+
.maybeSingle();
|
|
69
|
+
|
|
70
|
+
const publicVal = (publicSetting?.value || {}) as Record<string, any>;
|
|
71
|
+
const secretVal = (secretSetting?.value || {}) as Record<string, any>;
|
|
72
|
+
|
|
73
|
+
const pinnedProvider =
|
|
74
|
+
options?.botProtectionProvider === 'turnstile' || options?.botProtectionProvider === 'recaptcha'
|
|
75
|
+
? options.botProtectionProvider
|
|
76
|
+
: undefined;
|
|
77
|
+
const provider: BotProtectionProvider = pinnedProvider || publicVal.provider || 'none';
|
|
78
|
+
const secretKey =
|
|
79
|
+
secretVal.secretKey ||
|
|
80
|
+
(provider === 'turnstile'
|
|
81
|
+
? process.env.TURNSTILE_SECRET_KEY
|
|
82
|
+
: process.env.RECAPTCHA_SECRET_KEY) ||
|
|
83
|
+
'';
|
|
84
|
+
|
|
85
|
+
if (provider === 'turnstile') {
|
|
86
|
+
const token = formData.get(TURNSTILE_TOKEN_FIELD) as string;
|
|
87
|
+
if (!token) {
|
|
88
|
+
return { ok: false, reason: 'captcha', message: 'Security verification token is missing. Please try again.' };
|
|
89
|
+
}
|
|
90
|
+
if (!secretKey) {
|
|
91
|
+
console.error('[Bot Protection] Turnstile secret key is not configured.');
|
|
92
|
+
return { ok: false, reason: 'captcha', message: 'Bot protection is misconfigured. Please contact support.' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
98
|
+
body: `secret=${encodeURIComponent(secretKey)}&response=${encodeURIComponent(token)}`,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const outcome = await res.json();
|
|
102
|
+
if (!outcome.success) {
|
|
103
|
+
console.warn('[Bot Protection] Turnstile verification failed:', outcome);
|
|
104
|
+
return { ok: false, reason: 'captcha', message: 'Security verification failed. Please try again.' };
|
|
105
|
+
}
|
|
106
|
+
} else if (provider === 'recaptcha') {
|
|
107
|
+
const token = formData.get(RECAPTCHA_TOKEN_FIELD) as string;
|
|
108
|
+
if (!token) {
|
|
109
|
+
return { ok: false, reason: 'captcha', message: 'Security verification token is missing. Please try again.' };
|
|
110
|
+
}
|
|
111
|
+
if (!secretKey) {
|
|
112
|
+
console.error('[Bot Protection] reCAPTCHA secret key is not configured.');
|
|
113
|
+
return { ok: false, reason: 'captcha', message: 'Bot protection is misconfigured. Please contact support.' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const res = await fetch('https://www.google.com/recaptcha/api/siteverify', {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
119
|
+
body: `secret=${encodeURIComponent(secretKey)}&response=${encodeURIComponent(token)}`,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const outcome = await res.json();
|
|
123
|
+
if (!outcome.success || outcome.score < 0.5) {
|
|
124
|
+
console.warn('[Bot Protection] reCAPTCHA verification failed:', outcome);
|
|
125
|
+
return { ok: false, reason: 'captcha', message: 'Security verification failed. Please try again.' };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.error('[Bot Protection] Error during validation:', error);
|
|
130
|
+
return { ok: false, reason: 'captcha', message: 'Sorry, security verification could not be completed at this time.' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { ok: true };
|
|
134
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
EMAIL_BRAND_HEADER_TOKEN,
|
|
5
|
+
applyEmailBranding,
|
|
6
|
+
pickEmailLogoObjectKey,
|
|
7
|
+
renderEmailBrandHeader,
|
|
8
|
+
} from './branding-format';
|
|
9
|
+
|
|
10
|
+
describe('renderEmailBrandHeader', () => {
|
|
11
|
+
it('renders an email-safe logo img capped at width 150 when a logo is set', () => {
|
|
12
|
+
const html = renderEmailBrandHeader({
|
|
13
|
+
logoUrl: 'https://cdn.example.com/logo.png',
|
|
14
|
+
siteName: 'Acme Co',
|
|
15
|
+
});
|
|
16
|
+
expect(html).toContain('<img');
|
|
17
|
+
expect(html).toContain('src="https://cdn.example.com/logo.png"');
|
|
18
|
+
expect(html).toContain('width="150"');
|
|
19
|
+
expect(html).toContain('alt="Acme Co"');
|
|
20
|
+
// width is also pinned in the inline style so clients that ignore the attribute obey it.
|
|
21
|
+
expect(html).toContain('width:150px');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('falls back to a text banner with the site name and no image when logo is null', () => {
|
|
25
|
+
const html = renderEmailBrandHeader({ logoUrl: null, siteName: 'Acme Co' });
|
|
26
|
+
expect(html).not.toContain('<img');
|
|
27
|
+
expect(html).toContain('Acme Co');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('falls back to the text banner when the logo url is an empty string', () => {
|
|
31
|
+
const html = renderEmailBrandHeader({ logoUrl: '', siteName: 'Acme Co' });
|
|
32
|
+
expect(html).not.toContain('<img');
|
|
33
|
+
expect(html).toContain('Acme Co');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('html-escapes the site name and logo url to prevent attribute/markup breakout', () => {
|
|
37
|
+
const html = renderEmailBrandHeader({
|
|
38
|
+
logoUrl: 'https://x.test/a"onerror="alert(1)',
|
|
39
|
+
siteName: 'A & B "Co" <script>',
|
|
40
|
+
});
|
|
41
|
+
expect(html).toContain('A & B "Co" <script>');
|
|
42
|
+
expect(html).not.toContain('onerror="alert(1)"');
|
|
43
|
+
expect(html).toContain('"onerror="');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('applyEmailBranding', () => {
|
|
48
|
+
it('replaces the brand-header token with the rendered logo header', () => {
|
|
49
|
+
const out = applyEmailBranding(`<div>${EMAIL_BRAND_HEADER_TOKEN}<p>Hi</p></div>`, {
|
|
50
|
+
logoUrl: 'https://cdn.example.com/logo.png',
|
|
51
|
+
siteName: 'Acme Co',
|
|
52
|
+
});
|
|
53
|
+
expect(out).not.toContain(EMAIL_BRAND_HEADER_TOKEN);
|
|
54
|
+
expect(out).toContain('src="https://cdn.example.com/logo.png"');
|
|
55
|
+
expect(out).toContain('<p>Hi</p>');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('replaces every occurrence of the token', () => {
|
|
59
|
+
const out = applyEmailBranding(
|
|
60
|
+
`${EMAIL_BRAND_HEADER_TOKEN}|${EMAIL_BRAND_HEADER_TOKEN}`,
|
|
61
|
+
{ logoUrl: null, siteName: 'Acme Co' },
|
|
62
|
+
);
|
|
63
|
+
expect(out).not.toContain(EMAIL_BRAND_HEADER_TOKEN);
|
|
64
|
+
expect(out.match(/Acme Co/g)?.length).toBe(2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('defensively swaps a stray hardcoded legacy NextBlock logo img for the tenant header', () => {
|
|
68
|
+
const legacy =
|
|
69
|
+
'<img src="https://nextblock.dev/images/nextblock-logo-small.webp" alt="Site logo" width="88" />';
|
|
70
|
+
const out = applyEmailBranding(`<td>${legacy}</td>`, {
|
|
71
|
+
logoUrl: 'https://cdn.example.com/logo.png',
|
|
72
|
+
siteName: 'Acme Co',
|
|
73
|
+
});
|
|
74
|
+
expect(out).not.toContain('nextblock-logo-small.webp');
|
|
75
|
+
expect(out).toContain('src="https://cdn.example.com/logo.png"');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('swaps a stray legacy logo for the text banner when no tenant logo is set', () => {
|
|
79
|
+
const legacy =
|
|
80
|
+
'<img src="https://nextblock.dev/images/nextblock-logo-small.webp" alt="Site logo" width="88" />';
|
|
81
|
+
const out = applyEmailBranding(legacy, { logoUrl: null, siteName: 'Acme Co' });
|
|
82
|
+
expect(out).not.toContain('<img');
|
|
83
|
+
expect(out).toContain('Acme Co');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('leaves an unrelated body untouched', () => {
|
|
87
|
+
const body = '<h2>New Form Submission</h2>';
|
|
88
|
+
expect(applyEmailBranding(body, { logoUrl: null, siteName: 'Acme Co' })).toBe(body);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('pickEmailLogoObjectKey', () => {
|
|
93
|
+
it('prefers the original_uploaded variant over the AVIF object_key', () => {
|
|
94
|
+
const media = {
|
|
95
|
+
object_key: 'uploads/logo_original.avif',
|
|
96
|
+
file_path: 'uploads/logo_original.avif',
|
|
97
|
+
variants: [
|
|
98
|
+
{ objectKey: 'uploads/logo_large.avif', variantLabel: 'large_avif', fileType: 'image/avif' },
|
|
99
|
+
{ objectKey: 'uploads/logo.png', variantLabel: 'original_uploaded', fileType: 'image/png' },
|
|
100
|
+
{ objectKey: 'uploads/logo_original.avif', variantLabel: 'original_avif', fileType: 'image/avif' },
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
expect(pickEmailLogoObjectKey(media)).toBe('uploads/logo.png');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('falls back to object_key when there is no original_uploaded variant (seeded default)', () => {
|
|
107
|
+
const media = {
|
|
108
|
+
object_key: 'images/nextblock-logo-small.webp',
|
|
109
|
+
file_path: null,
|
|
110
|
+
variants: null,
|
|
111
|
+
};
|
|
112
|
+
expect(pickEmailLogoObjectKey(media)).toBe('images/nextblock-logo-small.webp');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('falls back to file_path when object_key is missing', () => {
|
|
116
|
+
expect(
|
|
117
|
+
pickEmailLogoObjectKey({ object_key: null, file_path: 'uploads/legacy.png', variants: [] }),
|
|
118
|
+
).toBe('uploads/legacy.png');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('ignores an original_uploaded entry with a blank key and falls back', () => {
|
|
122
|
+
const media = {
|
|
123
|
+
object_key: 'uploads/logo_original.avif',
|
|
124
|
+
variants: [{ objectKey: '', variantLabel: 'original_uploaded' }],
|
|
125
|
+
};
|
|
126
|
+
expect(pickEmailLogoObjectKey(media)).toBe('uploads/logo_original.avif');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns null when there is no media', () => {
|
|
130
|
+
expect(pickEmailLogoObjectKey(null)).toBeNull();
|
|
131
|
+
expect(pickEmailLogoObjectKey(undefined)).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
});
|