@akinon/projectzero 2.0.79-beta.0 → 2.0.79-beta.1
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/CHANGELOG.md +2 -0
- package/app-template/.env.example +10 -0
- package/app-template/CHANGELOG.md +33 -0
- package/app-template/package.json +29 -29
- package/app-template/src/app/[pz]/layout.tsx +20 -0
- package/app-template/src/app/api/preview/link/route.ts +115 -0
- package/app-template/src/app/api/preview/route.ts +96 -0
- package/app-template/src/proxy.ts +46 -2
- package/app-template/src/settings.js +17 -0
- package/app-template/src/utils/__tests__/preview-contract.test.ts +223 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -21,3 +21,13 @@ NEXT_PUBLIC_VIRTUAL_TRY_ON_API_URL=https://d2a26507c7094f359aba349b96a96881.lb.a
|
|
|
21
21
|
# CACHE_KEY_PREFIX=
|
|
22
22
|
|
|
23
23
|
# BF_CACHE=false
|
|
24
|
+
|
|
25
|
+
# Widget preview (theme editor named previews).
|
|
26
|
+
# PREVIEW_SECRET signs the expiring preview links the editor requests from
|
|
27
|
+
# /api/preview/link; it never travels in a url. Unset = preview disabled.
|
|
28
|
+
# OMNITRON_URL is the Omnitron the editor is signed into — the link endpoint
|
|
29
|
+
# verifies the editor's session token there before minting a link.
|
|
30
|
+
# PREVIEW_LINK_TTL_SECONDS: how long a link stays valid (default 86400 = 24h).
|
|
31
|
+
# PREVIEW_SECRET=
|
|
32
|
+
# OMNITRON_URL=https://<omnitron-host>
|
|
33
|
+
# PREVIEW_LINK_TTL_SECONDS=86400
|
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# projectzeronext
|
|
2
2
|
|
|
3
|
+
## 2.0.79-beta.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [cbbbfd757]
|
|
8
|
+
- @akinon/next@2.0.79-beta.1
|
|
9
|
+
- @akinon/pz-theme@2.0.79-beta.1
|
|
10
|
+
- @akinon/pz-virtual-try-on@2.0.79-beta.1
|
|
11
|
+
- @akinon/pz-akifast@2.0.79-beta.1
|
|
12
|
+
- @akinon/pz-apple-pay@2.0.79-beta.1
|
|
13
|
+
- @akinon/pz-b2b@2.0.79-beta.1
|
|
14
|
+
- @akinon/pz-basket-gift-pack@2.0.79-beta.1
|
|
15
|
+
- @akinon/pz-bkm@2.0.79-beta.1
|
|
16
|
+
- @akinon/pz-checkout-gift-pack@2.0.79-beta.1
|
|
17
|
+
- @akinon/pz-click-collect@2.0.79-beta.1
|
|
18
|
+
- @akinon/pz-credit-payment@2.0.79-beta.1
|
|
19
|
+
- @akinon/pz-cybersource-uc@2.0.79-beta.1
|
|
20
|
+
- @akinon/pz-flow-payment@2.0.79-beta.1
|
|
21
|
+
- @akinon/pz-google-pay@2.0.79-beta.1
|
|
22
|
+
- @akinon/pz-gpay@2.0.79-beta.1
|
|
23
|
+
- @akinon/pz-haso@2.0.79-beta.1
|
|
24
|
+
- @akinon/pz-hepsipay@2.0.79-beta.1
|
|
25
|
+
- @akinon/pz-masterpass@2.0.79-beta.1
|
|
26
|
+
- @akinon/pz-masterpass-rest@2.0.79-beta.1
|
|
27
|
+
- @akinon/pz-multi-basket@2.0.79-beta.1
|
|
28
|
+
- @akinon/pz-one-click-checkout@2.0.79-beta.1
|
|
29
|
+
- @akinon/pz-otp@2.0.79-beta.1
|
|
30
|
+
- @akinon/pz-pay-on-delivery@2.0.79-beta.1
|
|
31
|
+
- @akinon/pz-saved-card@2.0.79-beta.1
|
|
32
|
+
- @akinon/pz-similar-products@2.0.79-beta.1
|
|
33
|
+
- @akinon/pz-tabby-extension@2.0.79-beta.1
|
|
34
|
+
- @akinon/pz-tamara-extension@2.0.79-beta.1
|
|
35
|
+
|
|
3
36
|
## 2.0.79-beta.0
|
|
4
37
|
|
|
5
38
|
### Patch Changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "projectzeronext",
|
|
3
|
-
"version": "2.0.79-beta.
|
|
3
|
+
"version": "2.0.79-beta.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"scripts": {
|
|
@@ -24,33 +24,33 @@
|
|
|
24
24
|
"test:middleware": "jest middleware-matcher.test.ts --bail"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@akinon/next": "2.0.79-beta.
|
|
28
|
-
"@akinon/pz-akifast": "2.0.79-beta.
|
|
29
|
-
"@akinon/pz-apple-pay": "2.0.79-beta.
|
|
30
|
-
"@akinon/pz-b2b": "2.0.79-beta.
|
|
31
|
-
"@akinon/pz-basket-gift-pack": "2.0.79-beta.
|
|
32
|
-
"@akinon/pz-bkm": "2.0.79-beta.
|
|
33
|
-
"@akinon/pz-checkout-gift-pack": "2.0.79-beta.
|
|
34
|
-
"@akinon/pz-click-collect": "2.0.79-beta.
|
|
35
|
-
"@akinon/pz-credit-payment": "2.0.79-beta.
|
|
36
|
-
"@akinon/pz-cybersource-uc": "2.0.79-beta.
|
|
37
|
-
"@akinon/pz-flow-payment": "2.0.79-beta.
|
|
38
|
-
"@akinon/pz-google-pay": "2.0.79-beta.
|
|
39
|
-
"@akinon/pz-gpay": "2.0.79-beta.
|
|
40
|
-
"@akinon/pz-haso": "2.0.79-beta.
|
|
41
|
-
"@akinon/pz-hepsipay": "2.0.79-beta.
|
|
42
|
-
"@akinon/pz-masterpass": "2.0.79-beta.
|
|
43
|
-
"@akinon/pz-masterpass-rest": "2.0.79-beta.
|
|
44
|
-
"@akinon/pz-multi-basket": "2.0.79-beta.
|
|
45
|
-
"@akinon/pz-one-click-checkout": "2.0.79-beta.
|
|
46
|
-
"@akinon/pz-otp": "2.0.79-beta.
|
|
47
|
-
"@akinon/pz-pay-on-delivery": "2.0.79-beta.
|
|
48
|
-
"@akinon/pz-saved-card": "2.0.79-beta.
|
|
49
|
-
"@akinon/pz-similar-products": "2.0.79-beta.
|
|
50
|
-
"@akinon/pz-tabby-extension": "2.0.79-beta.
|
|
51
|
-
"@akinon/pz-tamara-extension": "2.0.79-beta.
|
|
52
|
-
"@akinon/pz-theme": "2.0.79-beta.
|
|
53
|
-
"@akinon/pz-virtual-try-on": "2.0.79-beta.
|
|
27
|
+
"@akinon/next": "2.0.79-beta.1",
|
|
28
|
+
"@akinon/pz-akifast": "2.0.79-beta.1",
|
|
29
|
+
"@akinon/pz-apple-pay": "2.0.79-beta.1",
|
|
30
|
+
"@akinon/pz-b2b": "2.0.79-beta.1",
|
|
31
|
+
"@akinon/pz-basket-gift-pack": "2.0.79-beta.1",
|
|
32
|
+
"@akinon/pz-bkm": "2.0.79-beta.1",
|
|
33
|
+
"@akinon/pz-checkout-gift-pack": "2.0.79-beta.1",
|
|
34
|
+
"@akinon/pz-click-collect": "2.0.79-beta.1",
|
|
35
|
+
"@akinon/pz-credit-payment": "2.0.79-beta.1",
|
|
36
|
+
"@akinon/pz-cybersource-uc": "2.0.79-beta.1",
|
|
37
|
+
"@akinon/pz-flow-payment": "2.0.79-beta.1",
|
|
38
|
+
"@akinon/pz-google-pay": "2.0.79-beta.1",
|
|
39
|
+
"@akinon/pz-gpay": "2.0.79-beta.1",
|
|
40
|
+
"@akinon/pz-haso": "2.0.79-beta.1",
|
|
41
|
+
"@akinon/pz-hepsipay": "2.0.79-beta.1",
|
|
42
|
+
"@akinon/pz-masterpass": "2.0.79-beta.1",
|
|
43
|
+
"@akinon/pz-masterpass-rest": "2.0.79-beta.1",
|
|
44
|
+
"@akinon/pz-multi-basket": "2.0.79-beta.1",
|
|
45
|
+
"@akinon/pz-one-click-checkout": "2.0.79-beta.1",
|
|
46
|
+
"@akinon/pz-otp": "2.0.79-beta.1",
|
|
47
|
+
"@akinon/pz-pay-on-delivery": "2.0.79-beta.1",
|
|
48
|
+
"@akinon/pz-saved-card": "2.0.79-beta.1",
|
|
49
|
+
"@akinon/pz-similar-products": "2.0.79-beta.1",
|
|
50
|
+
"@akinon/pz-tabby-extension": "2.0.79-beta.1",
|
|
51
|
+
"@akinon/pz-tamara-extension": "2.0.79-beta.1",
|
|
52
|
+
"@akinon/pz-theme": "2.0.79-beta.1",
|
|
53
|
+
"@akinon/pz-virtual-try-on": "2.0.79-beta.1",
|
|
54
54
|
"@hookform/resolvers": "2.9.0",
|
|
55
55
|
"@next/third-parties": "16.2.6",
|
|
56
56
|
"@react-google-maps/api": "2.17.1",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"yup": "0.32.11"
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
|
-
"@akinon/eslint-plugin-projectzero": "2.0.79-beta.
|
|
77
|
+
"@akinon/eslint-plugin-projectzero": "2.0.79-beta.1",
|
|
78
78
|
"@semantic-release/changelog": "6.0.2",
|
|
79
79
|
"@semantic-release/exec": "6.0.3",
|
|
80
80
|
"@semantic-release/git": "10.0.1",
|
|
@@ -14,6 +14,9 @@ import pwaTags from '@theme/components/pwa-tags';
|
|
|
14
14
|
import { OfflineFallback } from '@theme/components/offline-fallback';
|
|
15
15
|
import { InstallPrompt } from '@theme/components/install-prompt';
|
|
16
16
|
import type { Viewport } from 'next';
|
|
17
|
+
import { draftMode } from 'next/headers';
|
|
18
|
+
import { PREVIEW_PZ_SEGMENT } from '@akinon/next/utils/preview';
|
|
19
|
+
import { parsePzParams } from '@akinon/next/utils/pz-segments';
|
|
17
20
|
import settings from '@theme/settings';
|
|
18
21
|
import RouteHandler from '@theme/components/route-handler';
|
|
19
22
|
import { PreviewAuthStateReporter } from '@akinon/pz-theme/src/chrome/preview-auth-state';
|
|
@@ -55,8 +58,15 @@ export async function generateMetadata() {
|
|
|
55
58
|
async function RootLayout({
|
|
56
59
|
locale,
|
|
57
60
|
translations,
|
|
61
|
+
params,
|
|
58
62
|
children
|
|
59
63
|
}: ResolvedRootLayoutProps) {
|
|
64
|
+
// Widget preview indicator — reading draftMode does NOT opt the page into
|
|
65
|
+
// dynamic rendering; it is only true behind the /api/preview bypass cookie.
|
|
66
|
+
const { isEnabled: isPreview } = await draftMode();
|
|
67
|
+
// Which named preview is being viewed, so the banner says so — several can
|
|
68
|
+
// exist side by side and they look identical otherwise.
|
|
69
|
+
const previewId = parsePzParams(params, settings)[PREVIEW_PZ_SEGMENT] ?? '';
|
|
60
70
|
// Reconstructed here, above the error boundary, because the boundary is a
|
|
61
71
|
// client component that can neither fetch nor receive server props — see
|
|
62
72
|
// pz-theme's error-composition.tsx. Costs one memoized widget read until a
|
|
@@ -67,6 +77,16 @@ async function RootLayout({
|
|
|
67
77
|
<html lang={locale.isoCode} {...(locale.rtl ? { dir: 'rtl' } : {})}>
|
|
68
78
|
<head />
|
|
69
79
|
<body className="overflow-x-hidden">
|
|
80
|
+
{isPreview && (
|
|
81
|
+
<div className="fixed bottom-3 left-1/2 -translate-x-1/2 z-[9999] flex items-center gap-2 rounded bg-black/80 px-3 py-1.5 text-xs text-white">
|
|
82
|
+
<span>{previewId ? `Preview: ${previewId}` : 'Preview'}</span>
|
|
83
|
+
{/* Relative query-only href: exits preview and returns to the
|
|
84
|
+
SAME page via the middleware ?preview=exit interception. */}
|
|
85
|
+
<a href="?preview=exit" className="underline">
|
|
86
|
+
Exit
|
|
87
|
+
</a>
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
70
90
|
<PzRoot translations={translations} locale={locale.value}>
|
|
71
91
|
<ClientRoot>
|
|
72
92
|
<div className="overflow-x-clip">
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { isValidPreviewId, PREVIEW_ID_QUERY_PARAM } from '@akinon/next/utils/preview';
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_PREVIEW_LINK_TTL_SECONDS,
|
|
4
|
+
signPreviewToken
|
|
5
|
+
} from '@akinon/next/utils/preview-token';
|
|
6
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Mints a signed, expiring preview entry token for the theme editor.
|
|
10
|
+
*
|
|
11
|
+
* The editor is a static SPA on another origin and must never hold the
|
|
12
|
+
* storefront's PREVIEW_SECRET. Instead it proves it is an editor user by
|
|
13
|
+
* forwarding its Omnitron session token; this handler verifies that token
|
|
14
|
+
* against Omnitron (`OMNITRON_URL`) and answers with a token the editor puts
|
|
15
|
+
* on the link as `?preview=<token>`. The link opens exactly one preview and
|
|
16
|
+
* stops working after PREVIEW_LINK_TTL_SECONDS (default 24h).
|
|
17
|
+
*
|
|
18
|
+
* CORS is open on purpose: the request carries no cookies, only the caller's
|
|
19
|
+
* Omnitron token, and the response is worthless without one.
|
|
20
|
+
*/
|
|
21
|
+
const CORS_HEADERS = {
|
|
22
|
+
'Access-Control-Allow-Origin': '*',
|
|
23
|
+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
24
|
+
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
|
|
25
|
+
'Access-Control-Max-Age': '600',
|
|
26
|
+
'Cache-Control': 'no-store'
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const json = (body: Record<string, unknown>, status: number) =>
|
|
30
|
+
NextResponse.json(body, { status, headers: CORS_HEADERS });
|
|
31
|
+
|
|
32
|
+
export async function OPTIONS() {
|
|
33
|
+
return new NextResponse(null, { status: 204, headers: CORS_HEADERS });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Bounded wait for Omnitron — an unreachable host must fail fast, not hang the editor. */
|
|
37
|
+
const OMNITRON_TIMEOUT_MS = 8_000;
|
|
38
|
+
|
|
39
|
+
/** True when Omnitron accepts the token as a signed-in user. */
|
|
40
|
+
const isEditorUser = async (
|
|
41
|
+
omnitronUrl: string,
|
|
42
|
+
authorization: string
|
|
43
|
+
): Promise<boolean> => {
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetch(
|
|
46
|
+
`${omnitronUrl.replace(/\/+$/, '')}/api/v1/active_user/`,
|
|
47
|
+
{
|
|
48
|
+
headers: { Authorization: authorization, Accept: 'application/json' },
|
|
49
|
+
cache: 'no-store',
|
|
50
|
+
signal: AbortSignal.timeout(OMNITRON_TIMEOUT_MS)
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
return response.status === 200;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export async function GET(request: NextRequest) {
|
|
60
|
+
const secret = process.env.PREVIEW_SECRET;
|
|
61
|
+
const omnitronUrl = process.env.OMNITRON_URL;
|
|
62
|
+
if (!secret || !omnitronUrl) {
|
|
63
|
+
return json(
|
|
64
|
+
{
|
|
65
|
+
error: 'preview-disabled',
|
|
66
|
+
message:
|
|
67
|
+
'Preview links are disabled: PREVIEW_SECRET and OMNITRON_URL must both be configured on the storefront.'
|
|
68
|
+
},
|
|
69
|
+
503
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const previewId =
|
|
74
|
+
request.nextUrl.searchParams.get(PREVIEW_ID_QUERY_PARAM) ?? '';
|
|
75
|
+
if (!isValidPreviewId(previewId)) {
|
|
76
|
+
return json(
|
|
77
|
+
{ error: 'invalid-preview-id', message: 'A valid preview_id is required.' },
|
|
78
|
+
400
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const authorization = request.headers.get('authorization') ?? '';
|
|
83
|
+
if (!/^Token \S+$/i.test(authorization)) {
|
|
84
|
+
return json(
|
|
85
|
+
{ error: 'unauthorized', message: 'An Omnitron token is required.' },
|
|
86
|
+
401
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (!(await isEditorUser(omnitronUrl, authorization))) {
|
|
90
|
+
return json(
|
|
91
|
+
{
|
|
92
|
+
error: 'unauthorized',
|
|
93
|
+
message: `Omnitron at ${omnitronUrl} did not accept the token (or did not answer).`
|
|
94
|
+
},
|
|
95
|
+
401
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const ttl =
|
|
100
|
+
Number(process.env.PREVIEW_LINK_TTL_SECONDS) ||
|
|
101
|
+
DEFAULT_PREVIEW_LINK_TTL_SECONDS;
|
|
102
|
+
const expiresAt = Math.floor(Date.now() / 1000) + ttl;
|
|
103
|
+
const token = await signPreviewToken({ previewId, expiresAt }, secret);
|
|
104
|
+
|
|
105
|
+
return json(
|
|
106
|
+
{
|
|
107
|
+
token,
|
|
108
|
+
previewId,
|
|
109
|
+
expiresAt: new Date(expiresAt * 1000).toISOString(),
|
|
110
|
+
// Ready to append to any page url of this storefront.
|
|
111
|
+
query: `preview=${encodeURIComponent(token)}`
|
|
112
|
+
},
|
|
113
|
+
200
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { PREVIEW_ID_COOKIE, PREVIEW_ID_QUERY_PARAM } from '@akinon/next/utils/preview';
|
|
2
|
+
import { verifyPreviewToken } from '@akinon/next/utils/preview-token';
|
|
3
|
+
import { cookies, draftMode } from 'next/headers';
|
|
4
|
+
import { redirect } from 'next/navigation';
|
|
5
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Enables/disables Next.js Draft Mode for widget previews, and records WHICH
|
|
9
|
+
* named preview is being viewed.
|
|
10
|
+
*
|
|
11
|
+
* Visitors never call this directly — any page url carrying `?preview` is
|
|
12
|
+
* redirected here by the middleware (see src/proxy.ts), and redirected back
|
|
13
|
+
* to the same page once the cookies are set. With draft mode on, the widget
|
|
14
|
+
* fetch layer serves `<slug>-preview-<id>` copies (falling back to live).
|
|
15
|
+
*
|
|
16
|
+
* The preview id rides in its own cookie rather than the url: pages keep
|
|
17
|
+
* their clean addresses and links followed afterwards stay inside the same
|
|
18
|
+
* preview. The cookie is folded into the pz route segment by the middleware
|
|
19
|
+
* (see settings.js `pzSegments`) because the widget pages are force-static,
|
|
20
|
+
* where Next.js blanks `cookies()` inside the render.
|
|
21
|
+
*
|
|
22
|
+
* Deny-by-default: entering preview ALWAYS requires `?preview=<token>` — a
|
|
23
|
+
* signed, expiring token minted by /api/preview/link for an editor user (see
|
|
24
|
+
* utils/preview-token.ts); PREVIEW_SECRET is the signing key and never
|
|
25
|
+
* travels in a url. Environments without the env have preview disabled
|
|
26
|
+
* entirely. Exiting never requires a token.
|
|
27
|
+
*/
|
|
28
|
+
export async function GET(request: NextRequest) {
|
|
29
|
+
const { searchParams } = request.nextUrl;
|
|
30
|
+
|
|
31
|
+
// Only ever redirect within the site — the target comes from the query.
|
|
32
|
+
const target = searchParams.get('redirect') ?? '/';
|
|
33
|
+
let safeTarget =
|
|
34
|
+
target.startsWith('/') && !target.startsWith('//') ? target : '/';
|
|
35
|
+
// Never bounce back to a url that still carries ?preview — that would
|
|
36
|
+
// re-enter the middleware redirect and loop.
|
|
37
|
+
const targetUrl = new URL(safeTarget, request.nextUrl.origin);
|
|
38
|
+
targetUrl.searchParams.delete('preview');
|
|
39
|
+
targetUrl.searchParams.delete(PREVIEW_ID_QUERY_PARAM);
|
|
40
|
+
safeTarget = `${targetUrl.pathname}${targetUrl.search}`;
|
|
41
|
+
// URL normalization can reintroduce a protocol-relative target the first
|
|
42
|
+
// guard already rejected (e.g. '/..//evil.com' normalizes to pathname
|
|
43
|
+
// '//evil.com', which browsers resolve to https://evil.com) — re-check the
|
|
44
|
+
// FINAL string, not just the raw input.
|
|
45
|
+
if (!safeTarget.startsWith('/') || safeTarget.startsWith('//')) {
|
|
46
|
+
safeTarget = '/';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const draft = await draftMode();
|
|
50
|
+
const cookieStore = await cookies();
|
|
51
|
+
|
|
52
|
+
if (searchParams.has('exit')) {
|
|
53
|
+
draft.disable();
|
|
54
|
+
cookieStore.delete(PREVIEW_ID_COOKIE);
|
|
55
|
+
redirect(safeTarget);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const secret = process.env.PREVIEW_SECRET;
|
|
59
|
+
if (!secret) {
|
|
60
|
+
return new NextResponse(
|
|
61
|
+
'Preview is disabled: PREVIEW_SECRET is not configured.',
|
|
62
|
+
{ status: 401 }
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const verdict = await verifyPreviewToken(
|
|
67
|
+
searchParams.get('token') ?? '',
|
|
68
|
+
secret
|
|
69
|
+
);
|
|
70
|
+
if (verdict.ok !== true) {
|
|
71
|
+
const { reason } = verdict;
|
|
72
|
+
return new NextResponse(
|
|
73
|
+
reason === 'expired'
|
|
74
|
+
? 'This preview link has expired — open the page from the editor again to get a fresh one.'
|
|
75
|
+
: 'Preview link is invalid or missing. Preview pages are opened from the theme editor.',
|
|
76
|
+
{ status: 401 }
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const { previewId } = verdict.claims;
|
|
80
|
+
|
|
81
|
+
draft.enable();
|
|
82
|
+
// Same flags Next gives its own draft cookie (SameSite=None; Secure outside
|
|
83
|
+
// development): the storefront may be embedded cross-site (editor,
|
|
84
|
+
// Omnitron), and a Lax id cookie would be dropped there while the draft
|
|
85
|
+
// cookie survives — draft mode on, no id, live content under a "Preview"
|
|
86
|
+
// banner. `nextUrl.protocol` is not consulted: behind a TLS-terminating
|
|
87
|
+
// proxy it reads http even in production.
|
|
88
|
+
const isDevelopment = process.env.NODE_ENV === 'development';
|
|
89
|
+
cookieStore.set(PREVIEW_ID_COOKIE, previewId, {
|
|
90
|
+
path: '/',
|
|
91
|
+
httpOnly: true,
|
|
92
|
+
sameSite: isDevelopment ? 'lax' : 'none',
|
|
93
|
+
secure: !isDevelopment
|
|
94
|
+
});
|
|
95
|
+
redirect(safeTarget);
|
|
96
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { withPzDefault } from '@akinon/next/middlewares';
|
|
1
|
+
import { PzNextRequest, withPzDefault } from '@akinon/next/middlewares';
|
|
2
|
+
import { PREVIEW_ID_QUERY_PARAM } from '@akinon/next/utils/preview';
|
|
2
3
|
import { NextMiddleware, NextResponse } from 'next/server';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -32,4 +33,47 @@ const proxy: NextMiddleware = () =>
|
|
|
32
33
|
return NextResponse.next();
|
|
33
34
|
};
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
const pzMiddleware = withPzDefault(proxy);
|
|
37
|
+
|
|
38
|
+
const PREVIEW_EXIT_VALUES = new Set(['exit', 'off', '0', 'false']);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Widget preview entry: any page url carrying `?preview` is redirected to the
|
|
42
|
+
* draft-mode route handler (/api/preview), which sets/clears the bypass and
|
|
43
|
+
* preview-id cookies and returns to the same page without the params. Runs
|
|
44
|
+
* BEFORE withPzDefault so the redirect is not subject to its response
|
|
45
|
+
* rewriting (pz-override-response).
|
|
46
|
+
*
|
|
47
|
+
* The `?preview` value is a signed token naming WHICH preview to view (see
|
|
48
|
+
* utils/preview-token.ts); it travels only as far as the route handler, which
|
|
49
|
+
* stores the preview id for the rest of the session. A stray `preview_id`
|
|
50
|
+
* from an old-style link is dropped from the return url.
|
|
51
|
+
*/
|
|
52
|
+
const middleware: NextMiddleware = (req, event) => {
|
|
53
|
+
const url = req.nextUrl;
|
|
54
|
+
|
|
55
|
+
if (url.searchParams.has('preview')) {
|
|
56
|
+
const value = url.searchParams.get('preview') ?? '';
|
|
57
|
+
const target = url.clone();
|
|
58
|
+
target.searchParams.delete('preview');
|
|
59
|
+
target.searchParams.delete(PREVIEW_ID_QUERY_PARAM);
|
|
60
|
+
|
|
61
|
+
const previewApi = url.clone();
|
|
62
|
+
previewApi.pathname = '/api/preview';
|
|
63
|
+
previewApi.search = '';
|
|
64
|
+
previewApi.searchParams.set(
|
|
65
|
+
'redirect',
|
|
66
|
+
`${target.pathname}${target.search}`
|
|
67
|
+
);
|
|
68
|
+
if (PREVIEW_EXIT_VALUES.has(value.toLowerCase())) {
|
|
69
|
+
previewApi.searchParams.set('exit', '1');
|
|
70
|
+
} else {
|
|
71
|
+
if (value) previewApi.searchParams.set('token', value);
|
|
72
|
+
}
|
|
73
|
+
return NextResponse.redirect(previewApi);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return pzMiddleware(req as PzNextRequest, event);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export default middleware;
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
const { LocaleUrlStrategy } = require('@akinon/next/localization');
|
|
2
|
+
const {
|
|
3
|
+
PREVIEW_PZ_SEGMENT,
|
|
4
|
+
resolvePreviewSegment
|
|
5
|
+
} = require('@akinon/next/utils/preview');
|
|
2
6
|
const { ROUTES } = require('@theme/routes');
|
|
3
7
|
|
|
4
8
|
const commerceUrl = encodeURI(process.env.SERVICE_BACKEND_URL ?? 'default');
|
|
@@ -64,6 +68,19 @@ module.exports = {
|
|
|
64
68
|
},
|
|
65
69
|
customNotFoundEnabled: false,
|
|
66
70
|
usePzSegment: true,
|
|
71
|
+
pzSegments: {
|
|
72
|
+
segments: [
|
|
73
|
+
{
|
|
74
|
+
// Carries WHICH named preview is being viewed into the render. The
|
|
75
|
+
// widget pages are force-static, where cookies() is blanked, so the
|
|
76
|
+
// middleware folds the preview cookie into the route instead.
|
|
77
|
+
// Empty for ordinary visitors — nothing renders from preview unless
|
|
78
|
+
// draft mode is on as well.
|
|
79
|
+
name: PREVIEW_PZ_SEGMENT,
|
|
80
|
+
resolve: (context) => resolvePreviewSegment(context.req)
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
},
|
|
67
84
|
commerceRedirectionIgnoreList: ['/users/reset'],
|
|
68
85
|
csrf: {
|
|
69
86
|
httpOnly: true
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isPreviewCopySlug,
|
|
3
|
+
isPreviewSlug,
|
|
4
|
+
isValidPreviewId,
|
|
5
|
+
previewSlug,
|
|
6
|
+
resolvePreviewSegment
|
|
7
|
+
} from '@akinon/next/utils/preview';
|
|
8
|
+
import {
|
|
9
|
+
decodePzValue,
|
|
10
|
+
encodePzValue,
|
|
11
|
+
getPzSegmentsConfig
|
|
12
|
+
} from '@akinon/next/utils/pz-segments';
|
|
13
|
+
import {
|
|
14
|
+
signPreviewToken,
|
|
15
|
+
verifyPreviewToken
|
|
16
|
+
} from '@akinon/next/utils/preview-token';
|
|
17
|
+
import { webcrypto } from 'node:crypto';
|
|
18
|
+
import { TextEncoder as NodeTextEncoder } from 'node:util';
|
|
19
|
+
|
|
20
|
+
// jsdom ships neither Web Crypto nor TextEncoder; the token code needs both.
|
|
21
|
+
if (!globalThis.crypto?.subtle) {
|
|
22
|
+
Object.defineProperty(globalThis, 'crypto', { value: webcrypto });
|
|
23
|
+
}
|
|
24
|
+
if (typeof globalThis.TextEncoder === 'undefined') {
|
|
25
|
+
Object.defineProperty(globalThis, 'TextEncoder', { value: NodeTextEncoder });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The half of the preview contract this app owns: which named preview a
|
|
30
|
+
* request belongs to, and the widget slug that preview's copies live under.
|
|
31
|
+
* The theme editor builds the same slugs — if these change, previews stop
|
|
32
|
+
* resolving and every page silently falls back to live.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const req = (cookie?: string, draft = true) => ({
|
|
36
|
+
cookies: {
|
|
37
|
+
get: (name: string) => {
|
|
38
|
+
if (name === '__prerender_bypass') {
|
|
39
|
+
return draft ? { value: 'bypass' } : undefined;
|
|
40
|
+
}
|
|
41
|
+
return name === 'pz-preview-id' && cookie !== undefined
|
|
42
|
+
? { value: cookie }
|
|
43
|
+
: undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('preview id validation', () => {
|
|
49
|
+
it('accepts the ids the editor produces', () => {
|
|
50
|
+
expect(isValidPreviewId('staging')).toBe(true);
|
|
51
|
+
expect(isValidPreviewId('black-friday')).toBe(true);
|
|
52
|
+
expect(isValidPreviewId('kis-kampanyasi-2026')).toBe(true);
|
|
53
|
+
expect(isValidPreviewId('a'.repeat(32))).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('rejects anything that could escape the slug', () => {
|
|
57
|
+
// Ids arrive from a cookie, so this is a trust boundary.
|
|
58
|
+
expect(isValidPreviewId('')).toBe(false);
|
|
59
|
+
expect(isValidPreviewId('-leading')).toBe(false);
|
|
60
|
+
expect(isValidPreviewId('Upper')).toBe(false);
|
|
61
|
+
expect(isValidPreviewId('has space')).toBe(false);
|
|
62
|
+
expect(isValidPreviewId('../etc')).toBe(false);
|
|
63
|
+
expect(isValidPreviewId('a'.repeat(33))).toBe(false);
|
|
64
|
+
expect(isValidPreviewId(undefined)).toBe(false);
|
|
65
|
+
// `--` is the pz segment separator; the editor never produces it.
|
|
66
|
+
expect(isValidPreviewId('a--b')).toBe(false);
|
|
67
|
+
expect(isValidPreviewId('trailing-')).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('preview slugs', () => {
|
|
72
|
+
it('suffixes the live slug with the preview id', () => {
|
|
73
|
+
expect(previewSlug('homepage-body-new', 'staging')).toBe(
|
|
74
|
+
'homepage-body-new-preview-staging'
|
|
75
|
+
);
|
|
76
|
+
expect(previewSlug('homepage-body-new', 'black-friday')).toBe(
|
|
77
|
+
'homepage-body-new-preview-black-friday'
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('recognises its own output so previews are never nested', () => {
|
|
82
|
+
expect(isPreviewSlug('homepage-body-new-preview-staging')).toBe(true);
|
|
83
|
+
expect(isPreviewSlug('homepage-body-new')).toBe(false);
|
|
84
|
+
// The old single-preview suffix carries no id and is not one of ours.
|
|
85
|
+
expect(isPreviewSlug('homepage-body-new-preview')).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('only treats a slug as a copy when it belongs to THIS preview', () => {
|
|
89
|
+
expect(isPreviewCopySlug('header-preview-staging', 'staging')).toBe(true);
|
|
90
|
+
// Another preview's copy is not ours to skip.
|
|
91
|
+
expect(isPreviewCopySlug('header-preview-winter', 'staging')).toBe(false);
|
|
92
|
+
// A live custom page whose name merely looks like a copy still gets its
|
|
93
|
+
// overlay — the shape-only check used to skip it and render live.
|
|
94
|
+
expect(isPreviewCopySlug('page-summer-preview-2026', 'staging')).toBe(
|
|
95
|
+
false
|
|
96
|
+
);
|
|
97
|
+
expect(isPreviewCopySlug('header', '')).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('the preview id inside the pz segment', () => {
|
|
102
|
+
const settings = {
|
|
103
|
+
pzSegments: { segments: [{ name: 'preview' }] }
|
|
104
|
+
};
|
|
105
|
+
const config = getPzSegmentsConfig(settings);
|
|
106
|
+
const url = encodeURIComponent('https://shop.test/collections/summer--sale');
|
|
107
|
+
|
|
108
|
+
it('round-trips a url containing the separator with a preview id after it', () => {
|
|
109
|
+
const encoded = encodePzValue(
|
|
110
|
+
{ locale: 'en', currency: 'usd', url, preview: 'staging' },
|
|
111
|
+
config
|
|
112
|
+
);
|
|
113
|
+
expect(decodePzValue(encoded, config)).toEqual({
|
|
114
|
+
locale: 'en',
|
|
115
|
+
currency: 'usd',
|
|
116
|
+
url,
|
|
117
|
+
preview: 'staging'
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('round-trips such a url when no preview is active', () => {
|
|
122
|
+
const encoded = encodePzValue(
|
|
123
|
+
{ locale: 'en', currency: 'usd', url, preview: '' },
|
|
124
|
+
config
|
|
125
|
+
);
|
|
126
|
+
expect(decodePzValue(encoded, config)).toEqual({
|
|
127
|
+
locale: 'en',
|
|
128
|
+
currency: 'usd',
|
|
129
|
+
url,
|
|
130
|
+
preview: ''
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('still reads a plain segment positionally', () => {
|
|
135
|
+
expect(decodePzValue('tr--try--%2F--kis', config)).toEqual({
|
|
136
|
+
locale: 'tr',
|
|
137
|
+
currency: 'try',
|
|
138
|
+
url: '%2F',
|
|
139
|
+
preview: 'kis'
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('resolvePreviewSegment', () => {
|
|
145
|
+
it('passes a valid cookie through to the pz segment', () => {
|
|
146
|
+
expect(resolvePreviewSegment(req('staging'))).toBe('staging');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('yields no preview for a missing or tampered cookie', () => {
|
|
150
|
+
expect(resolvePreviewSegment(req())).toBe('');
|
|
151
|
+
expect(resolvePreviewSegment(req(''))).toBe('');
|
|
152
|
+
expect(resolvePreviewSegment(req('../../evil'))).toBe('');
|
|
153
|
+
expect(resolvePreviewSegment(req('Staging'))).toBe('');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('ignores the id cookie unless draft mode is on', () => {
|
|
157
|
+
// An anonymous visitor forging pz-preview-id must not get a private
|
|
158
|
+
// cache key per request — without the draft cookie the id is inert.
|
|
159
|
+
expect(resolvePreviewSegment(req('staging', false))).toBe('');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('signed preview links', () => {
|
|
164
|
+
const secret = 'k3y';
|
|
165
|
+
const now = 1_800_000_000_000; // ms
|
|
166
|
+
const inAnHour = Math.floor(now / 1000) + 3600;
|
|
167
|
+
|
|
168
|
+
it('round-trips the preview id and expiry it was minted with', async () => {
|
|
169
|
+
const token = await signPreviewToken(
|
|
170
|
+
{ previewId: 'kis-kampanyasi', expiresAt: inAnHour },
|
|
171
|
+
secret
|
|
172
|
+
);
|
|
173
|
+
expect(token.startsWith('v1.kis-kampanyasi.')).toBe(true);
|
|
174
|
+
expect(await verifyPreviewToken(token, secret, now)).toEqual({
|
|
175
|
+
ok: true,
|
|
176
|
+
claims: { previewId: 'kis-kampanyasi', expiresAt: inAnHour }
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('dies at its expiry', async () => {
|
|
181
|
+
const token = await signPreviewToken(
|
|
182
|
+
{ previewId: 'staging', expiresAt: inAnHour },
|
|
183
|
+
secret
|
|
184
|
+
);
|
|
185
|
+
expect(await verifyPreviewToken(token, secret, inAnHour * 1000)).toEqual({
|
|
186
|
+
ok: false,
|
|
187
|
+
reason: 'expired'
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('rejects tampering with the id, the expiry or the key', async () => {
|
|
192
|
+
const token = await signPreviewToken(
|
|
193
|
+
{ previewId: 'staging', expiresAt: inAnHour },
|
|
194
|
+
secret
|
|
195
|
+
);
|
|
196
|
+
const [v, , exp, sig] = token.split('.');
|
|
197
|
+
expect(
|
|
198
|
+
(await verifyPreviewToken(`${v}.winter.${exp}.${sig}`, secret, now)).ok
|
|
199
|
+
).toBe(false);
|
|
200
|
+
expect(
|
|
201
|
+
(await verifyPreviewToken(`${v}.staging.${exp + 1}.${sig}`, secret, now))
|
|
202
|
+
.ok
|
|
203
|
+
).toBe(false);
|
|
204
|
+
expect((await verifyPreviewToken(token, 'other-key', now)).ok).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('treats anything else as malformed — including the old raw secret', async () => {
|
|
208
|
+
expect(await verifyPreviewToken('pz-preview-local', secret, now)).toEqual({
|
|
209
|
+
ok: false,
|
|
210
|
+
reason: 'malformed'
|
|
211
|
+
});
|
|
212
|
+
expect((await verifyPreviewToken('', secret, now)).ok).toBe(false);
|
|
213
|
+
expect(
|
|
214
|
+
(await verifyPreviewToken('v1.Bad Id.123.sig', secret, now)).ok
|
|
215
|
+
).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('refuses to mint for an invalid id', async () => {
|
|
219
|
+
await expect(
|
|
220
|
+
signPreviewToken({ previewId: '../x', expiresAt: inAnHour }, secret)
|
|
221
|
+
).rejects.toThrow();
|
|
222
|
+
});
|
|
223
|
+
});
|