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.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/(auth-pages)/sign-up/SignUpForm.tsx +141 -0
  3. package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +40 -122
  4. package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx +5 -0
  5. package/templates/nextblock-template/app/[slug]/page.tsx +5 -2
  6. package/templates/nextblock-template/app/[slug]/page.utils.ts +10 -9
  7. package/templates/nextblock-template/app/actions/email.ts +8 -1
  8. package/templates/nextblock-template/app/actions/feedback.ts +1 -0
  9. package/templates/nextblock-template/app/actions/formActions.ts +20 -97
  10. package/templates/nextblock-template/app/actions/interactions.ts +3 -2
  11. package/templates/nextblock-template/app/actions/twoFactorEmail.ts +3 -2
  12. package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +1 -1
  13. package/templates/nextblock-template/app/actions/visualEditingActions.ts +2 -0
  14. package/templates/nextblock-template/app/actions.ts +14 -0
  15. package/templates/nextblock-template/app/api/brand/email-logo/route.ts +48 -0
  16. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +47 -1
  17. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +4970 -8566
  18. package/templates/nextblock-template/app/article/[slug]/page.tsx +5 -2
  19. package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -0
  20. package/templates/nextblock-template/app/cms/pages/actions.ts +22 -18
  21. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +20 -2
  22. package/templates/nextblock-template/app/cms/posts/actions.ts +5 -0
  23. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +137 -133
  24. package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +36 -0
  25. package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +26 -1
  26. package/templates/nextblock-template/app/cms/settings/copyright/page.tsx +6 -2
  27. package/templates/nextblock-template/app/cms/settings/email/actions.ts +7 -3
  28. package/templates/nextblock-template/app/cms/settings/logos/actions.ts +29 -14
  29. package/templates/nextblock-template/app/cms/settings/logos/components/SetActiveLogoButton.tsx +42 -0
  30. package/templates/nextblock-template/app/cms/settings/logos/page.tsx +18 -5
  31. package/templates/nextblock-template/app/layout.tsx +29 -17
  32. package/templates/nextblock-template/app/lib/seo.test.ts +36 -0
  33. package/templates/nextblock-template/app/lib/seo.ts +32 -0
  34. package/templates/nextblock-template/app/product/[slug]/page.tsx +5 -2
  35. package/templates/nextblock-template/components/AppShell.tsx +16 -1
  36. package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -0
  37. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +36 -42
  38. package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +7 -0
  39. package/templates/nextblock-template/lib/botProtection/verify.ts +134 -0
  40. package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -0
  41. package/templates/nextblock-template/lib/email/branding-format.ts +123 -0
  42. package/templates/nextblock-template/lib/email/branding.ts +76 -0
  43. package/templates/nextblock-template/lib/logos/active-logo.ts +53 -0
  44. package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +1 -0
  45. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +8 -203
  46. package/templates/nextblock-template/lib/visual-editing/draft-content.ts +4 -2
  47. package/templates/nextblock-template/lib/visual-editing/mutations.ts +2 -2
  48. package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +1 -0
  49. package/templates/nextblock-template/package.json +1 -1
  50. package/templates/nextblock-template/proxy.ts +16 -0
  51. package/templates/nextblock-template/public/images/nextblock-logo-button-tiny.png +0 -0
  52. 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 current padded migration sequence in
165
- `libs/db/src/supabase/migrations` runs from:
166
-
167
- - `00000000000000`
168
- - through `00000000000024`
169
-
170
- The first files (`00000000000000` through `00000000000016`) are squashed,
171
- grouped baseline domains. Everything from `00000000000017` onward is an
172
- append-only forward migration added after the baseline.
173
-
174
- These files are already squashed and grouped. Several of them preserve older
175
- logical migration boundaries through embedded comment headers, so you will see
176
- historical section numbers inside a smaller set of physical files.
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
- `00000000000000_setup_foundation_and_enums.sql` as pending, do not replay
192
- them. Use `npm run db:migrate:repair-history:check`, then
193
- `npm run db:migrate:repair-history`, then rerun
194
- `npm run db:migrate:check`.
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
- | `00000000000000_setup_foundation_and_enums.sql` | Core | Shared enums and schema-level foundation |
202
- | `00000000000001_setup_cms_core.sql` | Core, CMS | settings, profiles, languages, media, translations, logos |
203
- | `00000000000002_setup_content_tables.sql` | CMS | pages, posts, blocks, navigation, revisions |
204
- | `00000000000003_setup_catalog_and_licensing.sql` | Commerce | catalog, variants, inventory cache tables, package activations, Freemius sync tables |
205
- | `00000000000004_setup_fulfillment_shipping_taxes_and_currencies.sql` | Fulfillment, Commerce | orders, shipping, taxes, currencies, price-map sync functions |
206
- | `00000000000005_setup_functions_and_triggers.sql` | Core, CMS, Fulfillment | auth/profile bootstrap, timestamps, invoice functions, inventory deduction, product upsert helpers |
207
- | `00000000000006_setup_rls_and_grants.sql` | Security | grants, RLS enablement, policies |
208
- | `00000000000007_setup_indexes.sql` | Core, CMS, Commerce | performance indexes across authoring and commerce tables |
209
- | `00000000000008_seed_platform_defaults.sql` | Seeds | baseline site settings, default languages, default currencies |
210
- | `00000000000009_seed_translations.sql` | Seeds | translation catalog |
211
- | `00000000000010_seed_content_scaffold.sql` | Seeds, CMS | starter content, scaffold pages, seeded copy |
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 &amp; B &quot;Co&quot; &lt;script&gt;');
42
+ expect(html).not.toContain('onerror="alert(1)"');
43
+ expect(html).toContain('&quot;onerror=&quot;');
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
+ });