create-brainerce-store 1.28.13 → 1.28.17
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/dist/index.js +1 -1
- package/messages/en.json +390 -389
- package/messages/he.json +390 -389
- package/package.json +46 -46
- package/templates/nextjs/base/next.config.ts +47 -47
- package/templates/nextjs/base/src/app/api/auth/logout/route.ts +15 -15
- package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +66 -66
- package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +76 -76
- package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +235 -229
- package/templates/nextjs/base/src/app/checkout/page.tsx +975 -975
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +73 -76
- package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +529 -501
- package/templates/nextjs/base/src/app/products/page.tsx +475 -482
- package/templates/nextjs/base/src/app/reset-password/page.tsx +138 -138
- package/templates/nextjs/base/src/components/auth/register-form.tsx +245 -245
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +416 -416
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +656 -656
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +88 -88
- package/templates/nextjs/base/src/lib/brainerce.ts.ejs +6 -2
- package/templates/nextjs/base/src/lib/csrf.ts +11 -11
- package/templates/nextjs/base/src/lib/nonce.ts +10 -10
- package/templates/nextjs/base/src/lib/safe-redirect.ts +45 -45
- package/templates/nextjs/base/src/lib/sanitize-html.ts +93 -93
- package/templates/nextjs/base/src/lib/validation.ts +37 -37
|
@@ -1,88 +1,88 @@
|
|
|
1
|
-
import type { Product } from 'brainerce';
|
|
2
|
-
import { getProductPriceInfo } from 'brainerce';
|
|
3
|
-
import { getNonce } from '@/lib/nonce';
|
|
4
|
-
|
|
5
|
-
interface ProductJsonLdProps {
|
|
6
|
-
product: Product;
|
|
7
|
-
url: string;
|
|
8
|
-
currency?: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export async function ProductJsonLd({ product, url, currency = 'USD' }: ProductJsonLdProps) {
|
|
12
|
-
const nonce = await getNonce();
|
|
13
|
-
const priceInfo = getProductPriceInfo(product);
|
|
14
|
-
const imageUrl = product.images?.[0]?.url;
|
|
15
|
-
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
16
|
-
|
|
17
|
-
const productJsonLd = {
|
|
18
|
-
'@context': 'https://schema.org',
|
|
19
|
-
'@type': 'Product',
|
|
20
|
-
name: product.name,
|
|
21
|
-
description: product.description || product.name,
|
|
22
|
-
image: imageUrl,
|
|
23
|
-
url,
|
|
24
|
-
sku: product.sku || product.id,
|
|
25
|
-
offers: {
|
|
26
|
-
'@type': 'Offer',
|
|
27
|
-
price: priceInfo.price,
|
|
28
|
-
priceCurrency: currency,
|
|
29
|
-
availability:
|
|
30
|
-
product.inventory?.canPurchase !== false
|
|
31
|
-
? 'https://schema.org/InStock'
|
|
32
|
-
: 'https://schema.org/OutOfStock',
|
|
33
|
-
url,
|
|
34
|
-
},
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const breadcrumbJsonLd = {
|
|
38
|
-
'@context': 'https://schema.org',
|
|
39
|
-
'@type': 'BreadcrumbList',
|
|
40
|
-
itemListElement: [
|
|
41
|
-
{
|
|
42
|
-
'@type': 'ListItem',
|
|
43
|
-
position: 1,
|
|
44
|
-
name: 'Home',
|
|
45
|
-
item: baseUrl || '/',
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
'@type': 'ListItem',
|
|
49
|
-
position: 2,
|
|
50
|
-
name: 'Products',
|
|
51
|
-
item: `${baseUrl}/products`,
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
'@type': 'ListItem',
|
|
55
|
-
position: 3,
|
|
56
|
-
name: product.name,
|
|
57
|
-
item: url,
|
|
58
|
-
},
|
|
59
|
-
],
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
return (
|
|
63
|
-
<>
|
|
64
|
-
<script
|
|
65
|
-
type="application/ld+json"
|
|
66
|
-
nonce={nonce}
|
|
67
|
-
suppressHydrationWarning
|
|
68
|
-
dangerouslySetInnerHTML={{ __html: serializeJsonLd(productJsonLd) }}
|
|
69
|
-
/>
|
|
70
|
-
<script
|
|
71
|
-
type="application/ld+json"
|
|
72
|
-
nonce={nonce}
|
|
73
|
-
suppressHydrationWarning
|
|
74
|
-
dangerouslySetInnerHTML={{ __html: serializeJsonLd(breadcrumbJsonLd) }}
|
|
75
|
-
/>
|
|
76
|
-
</>
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Serialize a JSON-LD object for embedding in a <script> tag. Escapes
|
|
81
|
-
// `<`, `>`, and `&` to \uXXXX so seller-controlled product fields cannot
|
|
82
|
-
// break out of the script element with `</script>` or inject HTML.
|
|
83
|
-
function serializeJsonLd(value: unknown): string {
|
|
84
|
-
return JSON.stringify(value)
|
|
85
|
-
.replace(/</g, '\\u003c')
|
|
86
|
-
.replace(/>/g, '\\u003e')
|
|
87
|
-
.replace(/&/g, '\\u0026');
|
|
88
|
-
}
|
|
1
|
+
import type { Product } from 'brainerce';
|
|
2
|
+
import { getProductPriceInfo } from 'brainerce';
|
|
3
|
+
import { getNonce } from '@/lib/nonce';
|
|
4
|
+
|
|
5
|
+
interface ProductJsonLdProps {
|
|
6
|
+
product: Product;
|
|
7
|
+
url: string;
|
|
8
|
+
currency?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function ProductJsonLd({ product, url, currency = 'USD' }: ProductJsonLdProps) {
|
|
12
|
+
const nonce = await getNonce();
|
|
13
|
+
const priceInfo = getProductPriceInfo(product);
|
|
14
|
+
const imageUrl = product.images?.[0]?.url;
|
|
15
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
16
|
+
|
|
17
|
+
const productJsonLd = {
|
|
18
|
+
'@context': 'https://schema.org',
|
|
19
|
+
'@type': 'Product',
|
|
20
|
+
name: product.name,
|
|
21
|
+
description: product.description || product.name,
|
|
22
|
+
image: imageUrl,
|
|
23
|
+
url,
|
|
24
|
+
sku: product.sku || product.id,
|
|
25
|
+
offers: {
|
|
26
|
+
'@type': 'Offer',
|
|
27
|
+
price: priceInfo.price,
|
|
28
|
+
priceCurrency: currency,
|
|
29
|
+
availability:
|
|
30
|
+
product.inventory?.canPurchase !== false
|
|
31
|
+
? 'https://schema.org/InStock'
|
|
32
|
+
: 'https://schema.org/OutOfStock',
|
|
33
|
+
url,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const breadcrumbJsonLd = {
|
|
38
|
+
'@context': 'https://schema.org',
|
|
39
|
+
'@type': 'BreadcrumbList',
|
|
40
|
+
itemListElement: [
|
|
41
|
+
{
|
|
42
|
+
'@type': 'ListItem',
|
|
43
|
+
position: 1,
|
|
44
|
+
name: 'Home',
|
|
45
|
+
item: baseUrl || '/',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
'@type': 'ListItem',
|
|
49
|
+
position: 2,
|
|
50
|
+
name: 'Products',
|
|
51
|
+
item: `${baseUrl}/products`,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
'@type': 'ListItem',
|
|
55
|
+
position: 3,
|
|
56
|
+
name: product.name,
|
|
57
|
+
item: url,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<>
|
|
64
|
+
<script
|
|
65
|
+
type="application/ld+json"
|
|
66
|
+
nonce={nonce}
|
|
67
|
+
suppressHydrationWarning
|
|
68
|
+
dangerouslySetInnerHTML={{ __html: serializeJsonLd(productJsonLd) }}
|
|
69
|
+
/>
|
|
70
|
+
<script
|
|
71
|
+
type="application/ld+json"
|
|
72
|
+
nonce={nonce}
|
|
73
|
+
suppressHydrationWarning
|
|
74
|
+
dangerouslySetInnerHTML={{ __html: serializeJsonLd(breadcrumbJsonLd) }}
|
|
75
|
+
/>
|
|
76
|
+
</>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Serialize a JSON-LD object for embedding in a <script> tag. Escapes
|
|
81
|
+
// `<`, `>`, and `&` to \uXXXX so seller-controlled product fields cannot
|
|
82
|
+
// break out of the script element with `</script>` or inject HTML.
|
|
83
|
+
function serializeJsonLd(value: unknown): string {
|
|
84
|
+
return JSON.stringify(value)
|
|
85
|
+
.replace(/</g, '\\u003c')
|
|
86
|
+
.replace(/>/g, '\\u003e')
|
|
87
|
+
.replace(/&/g, '\\u0026');
|
|
88
|
+
}
|
|
@@ -49,11 +49,15 @@ export function initClient(): BrainerceClient {
|
|
|
49
49
|
|
|
50
50
|
// Server-side client — calls backend directly (no proxy needed for public data)
|
|
51
51
|
// Used by Server Components for SSR data fetching (generateMetadata, page rendering)
|
|
52
|
-
export function getServerClient(): BrainerceClient {
|
|
52
|
+
export function getServerClient(locale?: string): BrainerceClient {
|
|
53
53
|
const apiUrl = process.env.BRAINERCE_API_URL || 'https://api.brainerce.com';
|
|
54
|
-
|
|
54
|
+
const client = new BrainerceClient({
|
|
55
55
|
connectionId: CONNECTION_ID,
|
|
56
56
|
baseUrl: apiUrl,
|
|
57
57
|
origin: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
|
|
58
58
|
});
|
|
59
|
+
if (locale) {
|
|
60
|
+
client.setLocale(locale);
|
|
61
|
+
}
|
|
62
|
+
return client;
|
|
59
63
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
-
|
|
3
|
-
export const CSRF_HEADER = 'x-requested-with';
|
|
4
|
-
export const CSRF_VALUE = 'brainerce';
|
|
5
|
-
|
|
6
|
-
export function checkCsrf(request: NextRequest): NextResponse | null {
|
|
7
|
-
if (request.headers.get(CSRF_HEADER) !== CSRF_VALUE) {
|
|
8
|
-
return NextResponse.json({ error: 'CSRF validation failed' }, { status: 403 });
|
|
9
|
-
}
|
|
10
|
-
return null;
|
|
11
|
-
}
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
|
|
3
|
+
export const CSRF_HEADER = 'x-requested-with';
|
|
4
|
+
export const CSRF_VALUE = 'brainerce';
|
|
5
|
+
|
|
6
|
+
export function checkCsrf(request: NextRequest): NextResponse | null {
|
|
7
|
+
if (request.headers.get(CSRF_HEADER) !== CSRF_VALUE) {
|
|
8
|
+
return NextResponse.json({ error: 'CSRF validation failed' }, { status: 403 });
|
|
9
|
+
}
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { headers } from 'next/headers';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Reads the per-request CSP nonce set by middleware.
|
|
5
|
-
* Use this in server components that render inline `<script>` tags so
|
|
6
|
-
* they comply with the strict CSP (no `unsafe-inline`).
|
|
7
|
-
*/
|
|
8
|
-
export async function getNonce(): Promise<string | undefined> {
|
|
9
|
-
return (await headers()).get('x-nonce') ?? undefined;
|
|
10
|
-
}
|
|
1
|
+
import { headers } from 'next/headers';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reads the per-request CSP nonce set by middleware.
|
|
5
|
+
* Use this in server components that render inline `<script>` tags so
|
|
6
|
+
* they comply with the strict CSP (no `unsafe-inline`).
|
|
7
|
+
*/
|
|
8
|
+
export async function getNonce(): Promise<string | undefined> {
|
|
9
|
+
return (await headers()).get('x-nonce') ?? undefined;
|
|
10
|
+
}
|
|
@@ -1,45 +1,45 @@
|
|
|
1
|
-
const ALLOWED_PAYMENT_HOSTS: readonly string[] = [
|
|
2
|
-
'checkout.stripe.com',
|
|
3
|
-
'js.stripe.com',
|
|
4
|
-
'hooks.stripe.com',
|
|
5
|
-
'www.paypal.com',
|
|
6
|
-
'www.sandbox.paypal.com',
|
|
7
|
-
'secure.cardcom.solutions',
|
|
8
|
-
'meshulam.co.il',
|
|
9
|
-
'grow.link',
|
|
10
|
-
'grow.security',
|
|
11
|
-
'creditguard.co.il',
|
|
12
|
-
];
|
|
13
|
-
|
|
14
|
-
export function isAllowedPaymentUrl(url: string): boolean {
|
|
15
|
-
if (!url || typeof url !== 'string') return false;
|
|
16
|
-
|
|
17
|
-
let parsed: URL;
|
|
18
|
-
try {
|
|
19
|
-
parsed = new URL(url);
|
|
20
|
-
} catch {
|
|
21
|
-
return false;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
if (parsed.protocol !== 'https:') return false;
|
|
25
|
-
|
|
26
|
-
const hostname = parsed.hostname.toLowerCase();
|
|
27
|
-
return ALLOWED_PAYMENT_HOSTS.some((host) => hostname === host || hostname.endsWith('.' + host));
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function safePaymentRedirect(url: string): void {
|
|
31
|
-
if (!isAllowedPaymentUrl(url)) {
|
|
32
|
-
throw new Error('Payment redirect URL is not in the allowlist');
|
|
33
|
-
}
|
|
34
|
-
if (typeof window !== 'undefined') {
|
|
35
|
-
window.location.href = url;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// CUID format used by Prisma for Checkout.id — c + 24 lowercase alphanumeric chars.
|
|
40
|
-
// Allow a small range to tolerate cuid2 (slightly different length).
|
|
41
|
-
const CHECKOUT_ID_RE = /^c[a-z0-9]{20,30}$/;
|
|
42
|
-
|
|
43
|
-
export function isValidCheckoutId(id: unknown): id is string {
|
|
44
|
-
return typeof id === 'string' && CHECKOUT_ID_RE.test(id);
|
|
45
|
-
}
|
|
1
|
+
const ALLOWED_PAYMENT_HOSTS: readonly string[] = [
|
|
2
|
+
'checkout.stripe.com',
|
|
3
|
+
'js.stripe.com',
|
|
4
|
+
'hooks.stripe.com',
|
|
5
|
+
'www.paypal.com',
|
|
6
|
+
'www.sandbox.paypal.com',
|
|
7
|
+
'secure.cardcom.solutions',
|
|
8
|
+
'meshulam.co.il',
|
|
9
|
+
'grow.link',
|
|
10
|
+
'grow.security',
|
|
11
|
+
'creditguard.co.il',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export function isAllowedPaymentUrl(url: string): boolean {
|
|
15
|
+
if (!url || typeof url !== 'string') return false;
|
|
16
|
+
|
|
17
|
+
let parsed: URL;
|
|
18
|
+
try {
|
|
19
|
+
parsed = new URL(url);
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (parsed.protocol !== 'https:') return false;
|
|
25
|
+
|
|
26
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
27
|
+
return ALLOWED_PAYMENT_HOSTS.some((host) => hostname === host || hostname.endsWith('.' + host));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function safePaymentRedirect(url: string): void {
|
|
31
|
+
if (!isAllowedPaymentUrl(url)) {
|
|
32
|
+
throw new Error('Payment redirect URL is not in the allowlist');
|
|
33
|
+
}
|
|
34
|
+
if (typeof window !== 'undefined') {
|
|
35
|
+
window.location.href = url;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// CUID format used by Prisma for Checkout.id — c + 24 lowercase alphanumeric chars.
|
|
40
|
+
// Allow a small range to tolerate cuid2 (slightly different length).
|
|
41
|
+
const CHECKOUT_ID_RE = /^c[a-z0-9]{20,30}$/;
|
|
42
|
+
|
|
43
|
+
export function isValidCheckoutId(id: unknown): id is string {
|
|
44
|
+
return typeof id === 'string' && CHECKOUT_ID_RE.test(id);
|
|
45
|
+
}
|
|
@@ -1,93 +1,93 @@
|
|
|
1
|
-
import DOMPurify from 'isomorphic-dompurify';
|
|
2
|
-
|
|
3
|
-
const TRUSTED_IFRAME_PREFIXES = [
|
|
4
|
-
'https://www.youtube.com/embed/',
|
|
5
|
-
'https://www.youtube-nocookie.com/embed/',
|
|
6
|
-
'https://player.vimeo.com/video/',
|
|
7
|
-
];
|
|
8
|
-
|
|
9
|
-
let hooksRegistered = false;
|
|
10
|
-
|
|
11
|
-
function registerHooksOnce() {
|
|
12
|
-
if (hooksRegistered) return;
|
|
13
|
-
hooksRegistered = true;
|
|
14
|
-
|
|
15
|
-
DOMPurify.addHook('uponSanitizeElement', (node, data) => {
|
|
16
|
-
if (data.tagName !== 'iframe') return;
|
|
17
|
-
const el = node as Element;
|
|
18
|
-
const src = el.getAttribute('src') || '';
|
|
19
|
-
const isTrusted = TRUSTED_IFRAME_PREFIXES.some((prefix) => src.startsWith(prefix));
|
|
20
|
-
if (!isTrusted) {
|
|
21
|
-
el.parentNode?.removeChild(el);
|
|
22
|
-
}
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
|
26
|
-
if (!('tagName' in node)) return;
|
|
27
|
-
const el = node as Element;
|
|
28
|
-
if (el.tagName === 'A' && el.getAttribute('target') === '_blank') {
|
|
29
|
-
el.setAttribute('rel', 'noopener noreferrer nofollow');
|
|
30
|
-
}
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function sanitizeProductHtml(html: string): string {
|
|
35
|
-
if (!html) return '';
|
|
36
|
-
registerHooksOnce();
|
|
37
|
-
|
|
38
|
-
return DOMPurify.sanitize(html, {
|
|
39
|
-
ALLOWED_TAGS: [
|
|
40
|
-
'p',
|
|
41
|
-
'br',
|
|
42
|
-
'strong',
|
|
43
|
-
'b',
|
|
44
|
-
'em',
|
|
45
|
-
'i',
|
|
46
|
-
'u',
|
|
47
|
-
's',
|
|
48
|
-
'strike',
|
|
49
|
-
'h1',
|
|
50
|
-
'h2',
|
|
51
|
-
'h3',
|
|
52
|
-
'h4',
|
|
53
|
-
'h5',
|
|
54
|
-
'h6',
|
|
55
|
-
'ul',
|
|
56
|
-
'ol',
|
|
57
|
-
'li',
|
|
58
|
-
'a',
|
|
59
|
-
'blockquote',
|
|
60
|
-
'code',
|
|
61
|
-
'pre',
|
|
62
|
-
'div',
|
|
63
|
-
'span',
|
|
64
|
-
'img',
|
|
65
|
-
'table',
|
|
66
|
-
'thead',
|
|
67
|
-
'tbody',
|
|
68
|
-
'tr',
|
|
69
|
-
'th',
|
|
70
|
-
'td',
|
|
71
|
-
'colgroup',
|
|
72
|
-
'col',
|
|
73
|
-
'iframe',
|
|
74
|
-
],
|
|
75
|
-
ALLOWED_ATTR: [
|
|
76
|
-
'href',
|
|
77
|
-
'target',
|
|
78
|
-
'rel',
|
|
79
|
-
'class',
|
|
80
|
-
'src',
|
|
81
|
-
'alt',
|
|
82
|
-
'width',
|
|
83
|
-
'height',
|
|
84
|
-
'colspan',
|
|
85
|
-
'rowspan',
|
|
86
|
-
'allow',
|
|
87
|
-
'allowfullscreen',
|
|
88
|
-
'frameborder',
|
|
89
|
-
'loading',
|
|
90
|
-
],
|
|
91
|
-
ALLOW_DATA_ATTR: false,
|
|
92
|
-
});
|
|
93
|
-
}
|
|
1
|
+
import DOMPurify from 'isomorphic-dompurify';
|
|
2
|
+
|
|
3
|
+
const TRUSTED_IFRAME_PREFIXES = [
|
|
4
|
+
'https://www.youtube.com/embed/',
|
|
5
|
+
'https://www.youtube-nocookie.com/embed/',
|
|
6
|
+
'https://player.vimeo.com/video/',
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
let hooksRegistered = false;
|
|
10
|
+
|
|
11
|
+
function registerHooksOnce() {
|
|
12
|
+
if (hooksRegistered) return;
|
|
13
|
+
hooksRegistered = true;
|
|
14
|
+
|
|
15
|
+
DOMPurify.addHook('uponSanitizeElement', (node, data) => {
|
|
16
|
+
if (data.tagName !== 'iframe') return;
|
|
17
|
+
const el = node as Element;
|
|
18
|
+
const src = el.getAttribute('src') || '';
|
|
19
|
+
const isTrusted = TRUSTED_IFRAME_PREFIXES.some((prefix) => src.startsWith(prefix));
|
|
20
|
+
if (!isTrusted) {
|
|
21
|
+
el.parentNode?.removeChild(el);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
|
26
|
+
if (!('tagName' in node)) return;
|
|
27
|
+
const el = node as Element;
|
|
28
|
+
if (el.tagName === 'A' && el.getAttribute('target') === '_blank') {
|
|
29
|
+
el.setAttribute('rel', 'noopener noreferrer nofollow');
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function sanitizeProductHtml(html: string): string {
|
|
35
|
+
if (!html) return '';
|
|
36
|
+
registerHooksOnce();
|
|
37
|
+
|
|
38
|
+
return DOMPurify.sanitize(html, {
|
|
39
|
+
ALLOWED_TAGS: [
|
|
40
|
+
'p',
|
|
41
|
+
'br',
|
|
42
|
+
'strong',
|
|
43
|
+
'b',
|
|
44
|
+
'em',
|
|
45
|
+
'i',
|
|
46
|
+
'u',
|
|
47
|
+
's',
|
|
48
|
+
'strike',
|
|
49
|
+
'h1',
|
|
50
|
+
'h2',
|
|
51
|
+
'h3',
|
|
52
|
+
'h4',
|
|
53
|
+
'h5',
|
|
54
|
+
'h6',
|
|
55
|
+
'ul',
|
|
56
|
+
'ol',
|
|
57
|
+
'li',
|
|
58
|
+
'a',
|
|
59
|
+
'blockquote',
|
|
60
|
+
'code',
|
|
61
|
+
'pre',
|
|
62
|
+
'div',
|
|
63
|
+
'span',
|
|
64
|
+
'img',
|
|
65
|
+
'table',
|
|
66
|
+
'thead',
|
|
67
|
+
'tbody',
|
|
68
|
+
'tr',
|
|
69
|
+
'th',
|
|
70
|
+
'td',
|
|
71
|
+
'colgroup',
|
|
72
|
+
'col',
|
|
73
|
+
'iframe',
|
|
74
|
+
],
|
|
75
|
+
ALLOWED_ATTR: [
|
|
76
|
+
'href',
|
|
77
|
+
'target',
|
|
78
|
+
'rel',
|
|
79
|
+
'class',
|
|
80
|
+
'src',
|
|
81
|
+
'alt',
|
|
82
|
+
'width',
|
|
83
|
+
'height',
|
|
84
|
+
'colspan',
|
|
85
|
+
'rowspan',
|
|
86
|
+
'allow',
|
|
87
|
+
'allowfullscreen',
|
|
88
|
+
'frameborder',
|
|
89
|
+
'loading',
|
|
90
|
+
],
|
|
91
|
+
ALLOW_DATA_ATTR: false,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
const EMAIL_REGEX =
|
|
2
|
-
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
|
|
3
|
-
|
|
4
|
-
export function isValidEmail(email: string): boolean {
|
|
5
|
-
if (!email || typeof email !== 'string') return false;
|
|
6
|
-
if (email.length > 254) return false;
|
|
7
|
-
return EMAIL_REGEX.test(email);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export type PasswordErrorCode =
|
|
11
|
-
| 'passwordRequired'
|
|
12
|
-
| 'passwordTooShort'
|
|
13
|
-
| 'passwordNoUppercase'
|
|
14
|
-
| 'passwordNoNumber'
|
|
15
|
-
| 'passwordNoSymbol';
|
|
16
|
-
|
|
17
|
-
export function getPasswordError(password: string): PasswordErrorCode | null {
|
|
18
|
-
if (!password || typeof password !== 'string') return 'passwordRequired';
|
|
19
|
-
if (password.length < 8) return 'passwordTooShort';
|
|
20
|
-
if (!/[A-Z]/.test(password)) return 'passwordNoUppercase';
|
|
21
|
-
if (!/[0-9]/.test(password)) return 'passwordNoNumber';
|
|
22
|
-
if (!/[^A-Za-z0-9]/.test(password)) return 'passwordNoSymbol';
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const PASSWORD_ERROR_MESSAGES: Record<PasswordErrorCode, string> = {
|
|
27
|
-
passwordRequired: 'Password is required',
|
|
28
|
-
passwordTooShort: 'Password must be at least 8 characters',
|
|
29
|
-
passwordNoUppercase: 'Password must contain at least one uppercase letter',
|
|
30
|
-
passwordNoNumber: 'Password must contain at least one number',
|
|
31
|
-
passwordNoSymbol: 'Password must contain at least one special character',
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
export function validatePassword(password: string): string | null {
|
|
35
|
-
const code = getPasswordError(password);
|
|
36
|
-
return code ? PASSWORD_ERROR_MESSAGES[code] : null;
|
|
37
|
-
}
|
|
1
|
+
const EMAIL_REGEX =
|
|
2
|
+
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
|
|
3
|
+
|
|
4
|
+
export function isValidEmail(email: string): boolean {
|
|
5
|
+
if (!email || typeof email !== 'string') return false;
|
|
6
|
+
if (email.length > 254) return false;
|
|
7
|
+
return EMAIL_REGEX.test(email);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type PasswordErrorCode =
|
|
11
|
+
| 'passwordRequired'
|
|
12
|
+
| 'passwordTooShort'
|
|
13
|
+
| 'passwordNoUppercase'
|
|
14
|
+
| 'passwordNoNumber'
|
|
15
|
+
| 'passwordNoSymbol';
|
|
16
|
+
|
|
17
|
+
export function getPasswordError(password: string): PasswordErrorCode | null {
|
|
18
|
+
if (!password || typeof password !== 'string') return 'passwordRequired';
|
|
19
|
+
if (password.length < 8) return 'passwordTooShort';
|
|
20
|
+
if (!/[A-Z]/.test(password)) return 'passwordNoUppercase';
|
|
21
|
+
if (!/[0-9]/.test(password)) return 'passwordNoNumber';
|
|
22
|
+
if (!/[^A-Za-z0-9]/.test(password)) return 'passwordNoSymbol';
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const PASSWORD_ERROR_MESSAGES: Record<PasswordErrorCode, string> = {
|
|
27
|
+
passwordRequired: 'Password is required',
|
|
28
|
+
passwordTooShort: 'Password must be at least 8 characters',
|
|
29
|
+
passwordNoUppercase: 'Password must contain at least one uppercase letter',
|
|
30
|
+
passwordNoNumber: 'Password must contain at least one number',
|
|
31
|
+
passwordNoSymbol: 'Password must contain at least one special character',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function validatePassword(password: string): string | null {
|
|
35
|
+
const code = getPasswordError(password);
|
|
36
|
+
return code ? PASSWORD_ERROR_MESSAGES[code] : null;
|
|
37
|
+
}
|