create-nextblock 0.11.3 → 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 +4967 -8677
- 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/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 -213
- 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
|
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Pure, dependency-free rendering of the transactional-email brand header. Kept separate
|
|
2
|
+
// from branding.ts (which is `server-only` and hits the DB) so this string logic — the
|
|
3
|
+
// part worth unit-testing — imports nothing server-side and runs anywhere.
|
|
4
|
+
|
|
5
|
+
// Tenant branding used to white-label every transactional email.
|
|
6
|
+
// - logoUrl — an ABSOLUTE, email-safe logo URL, or `null` when none is configured.
|
|
7
|
+
// - siteName — the site name, used as the logo `alt` text and the no-logo text banner.
|
|
8
|
+
export interface EmailBranding {
|
|
9
|
+
logoUrl: string | null;
|
|
10
|
+
siteName: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Marker an app-rendered email template drops where the brand header belongs.
|
|
15
|
+
* `applyEmailBranding` swaps it for the resolved logo (or text banner). This is a plain
|
|
16
|
+
* literal — NOT a Supabase/GoTrue `{{ .Var }}` merge tag — because it is substituted by
|
|
17
|
+
* our own nodemailer pipeline, never by Supabase.
|
|
18
|
+
*/
|
|
19
|
+
export const EMAIL_BRAND_HEADER_TOKEN = '{{brand_header}}';
|
|
20
|
+
|
|
21
|
+
/** Hard width cap so an oversized custom logo can't blow out Gmail/Outlook layouts. */
|
|
22
|
+
export const EMAIL_LOGO_MAX_WIDTH = 150;
|
|
23
|
+
|
|
24
|
+
// The pre-white-label hardcoded logo. The app templates no longer embed it, but any stray
|
|
25
|
+
// occurrence in an outbound email is swapped for the tenant header defensively.
|
|
26
|
+
const LEGACY_LOGO_IMG_RE =
|
|
27
|
+
/<img\b[^>]*\bsrc=["'][^"']*nextblock-logo-small\.webp["'][^>]*>/gi;
|
|
28
|
+
|
|
29
|
+
function escapeHtml(value: string): string {
|
|
30
|
+
return value
|
|
31
|
+
.replace(/&/g, '&')
|
|
32
|
+
.replace(/</g, '<')
|
|
33
|
+
.replace(/>/g, '>')
|
|
34
|
+
.replace(/"/g, '"')
|
|
35
|
+
.replace(/'/g, ''');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Render the branded header block: an email-safe logo `<img>` (hard-capped at 150px so a
|
|
40
|
+
* large custom logo can't break the layout) when a logo exists, otherwise a clean, styled
|
|
41
|
+
* text banner showing the site name.
|
|
42
|
+
*/
|
|
43
|
+
export function renderEmailBrandHeader(branding: EmailBranding): string {
|
|
44
|
+
const name = escapeHtml(branding.siteName);
|
|
45
|
+
|
|
46
|
+
if (branding.logoUrl) {
|
|
47
|
+
const src = escapeHtml(branding.logoUrl);
|
|
48
|
+
return (
|
|
49
|
+
'<div style="text-align:center;padding:0 0 24px;">' +
|
|
50
|
+
`<img src="${src}" alt="${name}" width="${EMAIL_LOGO_MAX_WIDTH}" ` +
|
|
51
|
+
`style="display:block;margin:0 auto;width:${EMAIL_LOGO_MAX_WIDTH}px;` +
|
|
52
|
+
'max-width:100%;height:auto;border:0;outline:none;text-decoration:none;" />' +
|
|
53
|
+
'</div>'
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// No logo → strip the image entirely and fall back to a text banner with the site name.
|
|
58
|
+
return (
|
|
59
|
+
'<div style="text-align:center;padding:0 0 24px;">' +
|
|
60
|
+
'<span style="display:inline-block;font-family:-apple-system,BlinkMacSystemFont,' +
|
|
61
|
+
"'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:22px;line-height:1.2;" +
|
|
62
|
+
`font-weight:700;color:#0f172a;letter-spacing:-0.01em;">${name}</span>` +
|
|
63
|
+
'</div>'
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Intercept a fully-rendered email body and apply tenant branding:
|
|
69
|
+
* 1. Replace the `{{brand_header}}` token (used by our app-rendered templates).
|
|
70
|
+
* 2. Defensively swap any stray hardcoded legacy NextBlock logo `<img>` for the header.
|
|
71
|
+
* Purely a string transform so it is trivially unit-testable and side-effect free.
|
|
72
|
+
*/
|
|
73
|
+
export function applyEmailBranding(html: string, branding: EmailBranding): string {
|
|
74
|
+
const header = renderEmailBrandHeader(branding);
|
|
75
|
+
let out = html;
|
|
76
|
+
if (out.includes(EMAIL_BRAND_HEADER_TOKEN)) {
|
|
77
|
+
out = out.split(EMAIL_BRAND_HEADER_TOKEN).join(header);
|
|
78
|
+
}
|
|
79
|
+
out = out.replace(LEGACY_LOGO_IMG_RE, header);
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The `variants` JSONB the upload pipeline writes for a media row (camelCase keys).
|
|
84
|
+
interface MediaVariant {
|
|
85
|
+
objectKey?: string | null;
|
|
86
|
+
variantLabel?: string | null;
|
|
87
|
+
fileType?: string | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The subset of a media row the email logo resolver needs.
|
|
91
|
+
export interface LogoMediaLike {
|
|
92
|
+
object_key?: string | null;
|
|
93
|
+
file_path?: string | null;
|
|
94
|
+
variants?: unknown;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Label the upload pipeline gives the untouched original file among a media row's variants. */
|
|
98
|
+
export const ORIGINAL_UPLOAD_VARIANT_LABEL = 'original_uploaded';
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Pick the storage key to use for a logo IN EMAIL.
|
|
102
|
+
*
|
|
103
|
+
* The image pipeline stores the AVIF derivative as `object_key` — that's what the website
|
|
104
|
+
* (navbar, blocks) renders for performance — and keeps the ORIGINAL uploaded file among
|
|
105
|
+
* `variants` under the label `original_uploaded`. Email clients, Outlook especially, can't
|
|
106
|
+
* render AVIF (or WebP), so for email we prefer that untouched original. Fall back to
|
|
107
|
+
* `object_key`/`file_path` only when no original variant was kept (e.g. the seeded default
|
|
108
|
+
* logo, which has no variants). This keeps the site on AVIF while email uses the original.
|
|
109
|
+
*/
|
|
110
|
+
export function pickEmailLogoObjectKey(media: LogoMediaLike | null | undefined): string | null {
|
|
111
|
+
if (!media) return null;
|
|
112
|
+
const variants = Array.isArray(media.variants) ? (media.variants as MediaVariant[]) : [];
|
|
113
|
+
const original = variants.find(
|
|
114
|
+
(v) =>
|
|
115
|
+
v &&
|
|
116
|
+
typeof v === 'object' &&
|
|
117
|
+
v.variantLabel === ORIGINAL_UPLOAD_VARIANT_LABEL &&
|
|
118
|
+
typeof v.objectKey === 'string' &&
|
|
119
|
+
v.objectKey.length > 0,
|
|
120
|
+
);
|
|
121
|
+
if (original?.objectKey) return original.objectKey;
|
|
122
|
+
return media.object_key ?? media.file_path ?? null;
|
|
123
|
+
}
|