create-nextblock 0.15.10 → 0.16.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/package.json +1 -1
- package/templates/nextblock-template/app/api/ai/seo/alt-text/route.ts +221 -0
- package/templates/nextblock-template/app/api/ai/seo/metadata/route.ts +186 -0
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +193 -1
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +7 -1
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +27 -0
- package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +406 -229
- package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +171 -6
- package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +254 -245
- package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +177 -2
- package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +28 -0
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +649 -406
- package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +33 -0
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +618 -383
- package/templates/nextblock-template/app/cms/settings/seo/RedirectsCard.tsx +514 -0
- package/templates/nextblock-template/app/cms/settings/seo/RobotsCard.tsx +529 -0
- package/templates/nextblock-template/app/cms/settings/seo/SeoSettingsClient.tsx +57 -0
- package/templates/nextblock-template/app/cms/settings/seo/actions.ts +448 -0
- package/templates/nextblock-template/app/cms/settings/seo/mappers.ts +93 -0
- package/templates/nextblock-template/app/cms/settings/seo/page.tsx +46 -0
- package/templates/nextblock-template/app/cms/settings/seo/require-admin.ts +47 -0
- package/templates/nextblock-template/app/layout.tsx +1 -1
- package/templates/nextblock-template/app/robots.ts +123 -0
- package/templates/nextblock-template/app/sitemap.ts +1 -1
- package/templates/nextblock-template/components/seo/GenerateMetaButton.tsx +137 -0
- package/templates/nextblock-template/components/seo/PageSeoAuditSection.tsx +244 -0
- package/templates/nextblock-template/components/seo/SeoAuditPanel.tsx +749 -0
- package/templates/nextblock-template/components/seo/SeoIssueList.tsx +195 -0
- package/templates/nextblock-template/components/seo/SeoScoreDial.tsx +144 -0
- package/templates/nextblock-template/components/seo/SocialPreview.tsx +243 -0
- package/templates/nextblock-template/components/seo/SocialPreviewDialog.tsx +110 -0
- package/templates/nextblock-template/lib/cortex-ai/alt-text-request.ts +86 -0
- package/templates/nextblock-template/lib/cortex-ai/sandbox-headers.ts +60 -0
- package/templates/nextblock-template/lib/seo/alt-text-write-back.test.ts +154 -0
- package/templates/nextblock-template/lib/seo/alt-text-write-back.ts +109 -0
- package/templates/nextblock-template/lib/seo/block-content.ts +123 -0
- package/templates/nextblock-template/lib/seo/fix-prompts.test.ts +242 -0
- package/templates/nextblock-template/lib/seo/fix-prompts.ts +204 -0
- package/templates/nextblock-template/lib/seo/page-audit-context.tsx +140 -0
- package/templates/nextblock-template/lib/seo/page-document.test.ts +350 -0
- package/templates/nextblock-template/lib/seo/page-document.ts +412 -0
- package/templates/nextblock-template/lib/seo/redirect-store.test.ts +479 -0
- package/templates/nextblock-template/lib/seo/redirect-store.ts +466 -0
- package/templates/nextblock-template/lib/seo/robots-settings-signature.test.ts +102 -0
- package/templates/nextblock-template/lib/seo/robots-settings-signature.ts +41 -0
- package/templates/nextblock-template/lib/seo/robots-txt.test.ts +370 -0
- package/templates/nextblock-template/lib/seo/robots-txt.ts +510 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +10 -0
- package/templates/nextblock-template/next-env.d.ts +2 -2
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/proxy.ts +240 -20
- package/templates/nextblock-template/app/robots.txt/route.ts +0 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-nextblock",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "Scaffold a production-ready NextBlock CMS project — the open-source, full-stack AI-native CMS for Next.js 16, Supabase, and Tailwind CSS.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
|
|
3
|
+
import { createClient, verifyPackageOnline } from '@nextblock-cms/db/server';
|
|
4
|
+
|
|
5
|
+
import { generateCortexAiAltText } from '@nextblock-cms/cortex';
|
|
6
|
+
import {
|
|
7
|
+
safeParseCortexAiModelSelection,
|
|
8
|
+
summarizeCortexAiRoutingError,
|
|
9
|
+
} from '@nextblock-cms/cortex';
|
|
10
|
+
|
|
11
|
+
import { z } from '../../../../../lib/zod-config';
|
|
12
|
+
|
|
13
|
+
export const dynamic = 'force-dynamic';
|
|
14
|
+
|
|
15
|
+
type SupabaseServerClient = ReturnType<typeof createClient>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The request contract for the alt-text endpoint.
|
|
19
|
+
*
|
|
20
|
+
* `z.strictObject` rather than `z.object`, for the same reason the rest of the
|
|
21
|
+
* Cortex surface uses it: an unknown key is almost always a caller that has drifted
|
|
22
|
+
* from the contract — a stale client still sending `mediaId`, or a hand-rolled
|
|
23
|
+
* fetch that guessed at the field names — and silently discarding it produces a
|
|
24
|
+
* generation that ignores half of what the caller asked for. Rejecting loudly at
|
|
25
|
+
* the boundary turns that into a 400 the developer can actually read.
|
|
26
|
+
*
|
|
27
|
+
* `maxLength` is validated only for shape (a positive integer), not for range. The
|
|
28
|
+
* engine in `ai-vision.ts` deliberately CLAMPS an out-of-range budget into its own
|
|
29
|
+
* 20..1000 bounds instead of throwing, precisely so that a careless call site
|
|
30
|
+
* cannot break a media upload flow; duplicating those bounds here would both
|
|
31
|
+
* contradict that decision and leave two copies of the same numbers free to drift
|
|
32
|
+
* apart.
|
|
33
|
+
*/
|
|
34
|
+
const altTextRequestSchema = z.strictObject({
|
|
35
|
+
context: z.string().max(2000).optional(),
|
|
36
|
+
imageUrl: z.string().min(1),
|
|
37
|
+
maxLength: z.number().int().positive().optional(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
async function requireCmsWriterAccess(supabase: SupabaseServerClient) {
|
|
41
|
+
const {
|
|
42
|
+
data: { user },
|
|
43
|
+
error: userError,
|
|
44
|
+
} = await supabase.auth.getUser();
|
|
45
|
+
|
|
46
|
+
if (userError || !user) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { data: profile, error: profileError } = await supabase
|
|
51
|
+
.from('profiles')
|
|
52
|
+
.select('role')
|
|
53
|
+
.eq('id', user.id)
|
|
54
|
+
.single();
|
|
55
|
+
|
|
56
|
+
if (profileError || !profile || !['ADMIN', 'WRITER'].includes(profile.role)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { userId: user.id };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function jsonError(message: string, status: number) {
|
|
64
|
+
return NextResponse.json({ error: message }, { status });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Return a human-readable reason the supplied image URL cannot be sent to a vision
|
|
69
|
+
* model, or `null` when it is usable.
|
|
70
|
+
*
|
|
71
|
+
* This duplicates a check that `generateCortexAiAltText` also performs, and it does
|
|
72
|
+
* so deliberately. The engine's copy throws, which would surface here as a 500 —
|
|
73
|
+
* the status code that means "the server broke" — when the truth is that the caller
|
|
74
|
+
* sent an unusable value and needs a 400 telling them exactly that. Running the
|
|
75
|
+
* check first gives the common mistake the right status and an actionable message,
|
|
76
|
+
* while the engine keeps its own guard for every other call site.
|
|
77
|
+
*
|
|
78
|
+
* The mistake being guarded against is concrete rather than hypothetical:
|
|
79
|
+
* `resolveMediaUrl()` returns a bare `/${objectKey}` whenever no R2 base URL is
|
|
80
|
+
* configured, so an install that has not finished its media setup hands every
|
|
81
|
+
* caller a site-relative path. Because the Cortex OpenRouter provider is created
|
|
82
|
+
* without `supportedUrls`, the AI SDK does not forward a link to the model — it
|
|
83
|
+
* downloads the image server-side and inlines it as base64 — so a relative path
|
|
84
|
+
* would fail inside the SDK's own fetch, once per model in the fallback chain, with
|
|
85
|
+
* an error that never mentions the actual problem.
|
|
86
|
+
*
|
|
87
|
+
* The scheme test is strict rather than allowlist-based: any absolute URL is
|
|
88
|
+
* acceptable as long as it is http or https, but `file:`, `data:`, and `blob:` are
|
|
89
|
+
* refused. Those are the schemes that would either read the server's own disk or
|
|
90
|
+
* push an unbounded inline payload through every model in the fallback chain.
|
|
91
|
+
*/
|
|
92
|
+
function describeUnusableImageUrl(imageUrl: string): string | null {
|
|
93
|
+
const trimmed = imageUrl.trim();
|
|
94
|
+
|
|
95
|
+
if (!trimmed) {
|
|
96
|
+
return 'An image URL is required to generate alt text.';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let parsed: URL;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
parsed = new URL(trimmed);
|
|
103
|
+
} catch {
|
|
104
|
+
return `Alt text generation needs an absolute http(s) image URL, but received "${trimmed}". A storage key or site-relative path has to be resolved to a publicly fetchable URL first, because the model provider downloads the image server-side rather than following the link.`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
108
|
+
return `Alt text generation needs an http(s) image URL, but received the "${parsed.protocol}" scheme.`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function POST(request: Request) {
|
|
115
|
+
try {
|
|
116
|
+
const supabase = createClient();
|
|
117
|
+
const access = await requireCmsWriterAccess(supabase);
|
|
118
|
+
|
|
119
|
+
if (!access) {
|
|
120
|
+
return jsonError('You do not have permission to generate image alt text.', 403);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The package gate that /api/ai/generate-blocks is missing (docs/08 follow-up
|
|
124
|
+
// #6). Alt-text generation is a paid Cortex AI feature that burns either the
|
|
125
|
+
// operator's own OpenRouter credit or the shared house key, so an install
|
|
126
|
+
// without an active `cortex-ai` activation must not reach a model at all. The
|
|
127
|
+
// package id is `cortex-ai`; `ai` is not a package id anywhere in this system.
|
|
128
|
+
const isCortexAiActive = await verifyPackageOnline('cortex-ai');
|
|
129
|
+
|
|
130
|
+
if (!isCortexAiActive) {
|
|
131
|
+
return jsonError('NextBlock Cortex AI is not active for this workspace.', 403);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const body = await request.json().catch(() => null);
|
|
135
|
+
const parsedRequest = altTextRequestSchema.safeParse(body);
|
|
136
|
+
|
|
137
|
+
if (!parsedRequest.success) {
|
|
138
|
+
return jsonError('Invalid Cortex AI alt text request.', 400);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const unusableImageUrlReason = describeUnusableImageUrl(parsedRequest.data.imageUrl);
|
|
142
|
+
|
|
143
|
+
if (unusableImageUrlReason) {
|
|
144
|
+
return jsonError(unusableImageUrlReason, 400);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Sandbox installs have no server-side OpenRouter credential at all: the demo
|
|
148
|
+
// visitor pastes their own key into the browser, it lives in localStorage under
|
|
149
|
+
// `cortex_ai_sandbox_openrouter_api_key`, and it rides along on these headers.
|
|
150
|
+
// The env guard matters as much as the headers do — outside a sandbox the
|
|
151
|
+
// server must never accept a caller-supplied credential, because that would let
|
|
152
|
+
// any authenticated writer redirect generation through a key the operator never
|
|
153
|
+
// chose.
|
|
154
|
+
const sandboxKey =
|
|
155
|
+
process.env.NEXT_PUBLIC_IS_SANDBOX === 'true'
|
|
156
|
+
? request.headers.get('x-sandbox-openrouter-key')
|
|
157
|
+
: null;
|
|
158
|
+
const sandboxModelRaw =
|
|
159
|
+
process.env.NEXT_PUBLIC_IS_SANDBOX === 'true'
|
|
160
|
+
? request.headers.get('x-sandbox-openrouter-model')
|
|
161
|
+
: null;
|
|
162
|
+
|
|
163
|
+
let modelSelection = null;
|
|
164
|
+
if (sandboxModelRaw) {
|
|
165
|
+
try {
|
|
166
|
+
modelSelection = safeParseCortexAiModelSelection(JSON.parse(sandboxModelRaw));
|
|
167
|
+
} catch {
|
|
168
|
+
// Ignore malformed sandbox model headers.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// `generateCortexAiAltText` takes a requested model id rather than a whole
|
|
173
|
+
// stored selection, because its vision routing policy has to decide for itself
|
|
174
|
+
// whether an id is plausibly multimodal before it will route an image to it.
|
|
175
|
+
// The header is still parsed through `safeParseCortexAiModelSelection` so that
|
|
176
|
+
// a malformed value is discarded rather than forwarded as a bogus id, and the
|
|
177
|
+
// id is only honoured alongside the sandbox key so that a caller-supplied model
|
|
178
|
+
// choice can never be applied to the operator's own credential.
|
|
179
|
+
const result = await generateCortexAiAltText({
|
|
180
|
+
apiKey: sandboxKey || undefined,
|
|
181
|
+
context: parsedRequest.data.context,
|
|
182
|
+
imageUrl: parsedRequest.data.imageUrl,
|
|
183
|
+
maxLength: parsedRequest.data.maxLength,
|
|
184
|
+
modelId: sandboxKey && modelSelection ? modelSelection.modelId : undefined,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return NextResponse.json(
|
|
188
|
+
{
|
|
189
|
+
altText: result.altText,
|
|
190
|
+
credentialSource: result.credentialSource,
|
|
191
|
+
modelId: result.modelId,
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
headers: {
|
|
195
|
+
'x-cortex-ai-credential-source': result.credentialSource,
|
|
196
|
+
'x-cortex-ai-model': result.modelId,
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
// A CortexAiRoutingError is recognised STRUCTURALLY rather than with
|
|
202
|
+
// `instanceof`. The class is defined in @nextblock-cms/cortex, which resolves to
|
|
203
|
+
// library source inside this monorepo but to a published package in a
|
|
204
|
+
// standalone install; when both shapes end up in one bundle the two class
|
|
205
|
+
// identities are not the same object and `instanceof` silently returns false,
|
|
206
|
+
// swallowing the per-model attempt log that is the only useful diagnostic for a
|
|
207
|
+
// routing failure.
|
|
208
|
+
if (error && typeof error === 'object' && 'attempts' in error) {
|
|
209
|
+
console.error(
|
|
210
|
+
'[Cortex AI] Failed to generate alt text after model attempts:',
|
|
211
|
+
JSON.stringify((error as { attempts: unknown }).attempts, null, 2)
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
console.error('[Cortex AI] Failed to generate alt text:', error);
|
|
216
|
+
return jsonError(
|
|
217
|
+
summarizeCortexAiRoutingError(error, 'Failed to generate image alt text.'),
|
|
218
|
+
500
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
|
|
3
|
+
import { createClient, verifyPackageOnline } from '@nextblock-cms/db/server';
|
|
4
|
+
|
|
5
|
+
import { generateCortexAiSeoMetadata } from '@nextblock-cms/cortex';
|
|
6
|
+
import {
|
|
7
|
+
safeParseCortexAiModelSelection,
|
|
8
|
+
summarizeCortexAiRoutingError,
|
|
9
|
+
} from '@nextblock-cms/cortex';
|
|
10
|
+
|
|
11
|
+
import { z } from '../../../../../lib/zod-config';
|
|
12
|
+
|
|
13
|
+
export const dynamic = 'force-dynamic';
|
|
14
|
+
|
|
15
|
+
type SupabaseServerClient = ReturnType<typeof createClient>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* How much page copy this endpoint will accept in one request.
|
|
19
|
+
*
|
|
20
|
+
* The engine truncates the content to `CORTEX_AI_SEO_METADATA_CONTENT_BUDGET`
|
|
21
|
+
* (6000 characters) before it ever reaches a model, so anything beyond that is
|
|
22
|
+
* discarded rather than summarised. This ceiling is therefore not a content rule —
|
|
23
|
+
* it is a request-body bound, set far above the engine's budget so that a genuinely
|
|
24
|
+
* long post is never rejected with a confusing 400, but low enough that a runaway
|
|
25
|
+
* client cannot push megabytes of HTML through the JSON parser on every keystroke
|
|
26
|
+
* of an editor that generates metadata as you type.
|
|
27
|
+
*/
|
|
28
|
+
const SEO_METADATA_CONTENT_REQUEST_LIMIT = 200_000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The request contract for the metadata endpoint.
|
|
32
|
+
*
|
|
33
|
+
* Strict, for the reason every Cortex request schema is strict: an unrecognised key
|
|
34
|
+
* means the caller and this route disagree about the contract, and the failure mode
|
|
35
|
+
* of quietly dropping it is a generation that ignored the focus keyword or the
|
|
36
|
+
* locale the caller thought it had supplied. A 400 naming the mismatch is far
|
|
37
|
+
* cheaper to debug than metadata that is merely subtly wrong.
|
|
38
|
+
*
|
|
39
|
+
* Only `content` is required. Everything else is a hint the engine folds into the
|
|
40
|
+
* prompt when present and omits entirely when absent, which is why each optional
|
|
41
|
+
* field is bounded but never given a minimum beyond one character — an empty string
|
|
42
|
+
* from a form field that the user left blank should not fail the whole request, and
|
|
43
|
+
* the engine's own `?.trim() || null` normalisation already treats blank as absent.
|
|
44
|
+
*/
|
|
45
|
+
const seoMetadataRequestSchema = z.strictObject({
|
|
46
|
+
content: z.string().min(1).max(SEO_METADATA_CONTENT_REQUEST_LIMIT),
|
|
47
|
+
focusKeyword: z.string().max(200).optional(),
|
|
48
|
+
locale: z.string().max(64).optional(),
|
|
49
|
+
siteTitle: z.string().max(200).optional(),
|
|
50
|
+
title: z.string().max(500).optional(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
async function requireCmsWriterAccess(supabase: SupabaseServerClient) {
|
|
54
|
+
const {
|
|
55
|
+
data: { user },
|
|
56
|
+
error: userError,
|
|
57
|
+
} = await supabase.auth.getUser();
|
|
58
|
+
|
|
59
|
+
if (userError || !user) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { data: profile, error: profileError } = await supabase
|
|
64
|
+
.from('profiles')
|
|
65
|
+
.select('role')
|
|
66
|
+
.eq('id', user.id)
|
|
67
|
+
.single();
|
|
68
|
+
|
|
69
|
+
if (profileError || !profile || !['ADMIN', 'WRITER'].includes(profile.role)) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { userId: user.id };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function jsonError(message: string, status: number) {
|
|
77
|
+
return NextResponse.json({ error: message }, { status });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function POST(request: Request) {
|
|
81
|
+
try {
|
|
82
|
+
const supabase = createClient();
|
|
83
|
+
const access = await requireCmsWriterAccess(supabase);
|
|
84
|
+
|
|
85
|
+
if (!access) {
|
|
86
|
+
return jsonError('You do not have permission to generate SEO metadata.', 403);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The package gate that /api/ai/generate-blocks is missing (docs/08 follow-up
|
|
90
|
+
// #6). Metadata generation is a paid Cortex AI feature that burns either the
|
|
91
|
+
// operator's own OpenRouter credit or the shared house key, so an install
|
|
92
|
+
// without an active `cortex-ai` activation must not reach a model at all. The
|
|
93
|
+
// package id is `cortex-ai`; `ai` is not a package id anywhere in this system.
|
|
94
|
+
const isCortexAiActive = await verifyPackageOnline('cortex-ai');
|
|
95
|
+
|
|
96
|
+
if (!isCortexAiActive) {
|
|
97
|
+
return jsonError('NextBlock Cortex AI is not active for this workspace.', 403);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const body = await request.json().catch(() => null);
|
|
101
|
+
const parsedRequest = seoMetadataRequestSchema.safeParse(body);
|
|
102
|
+
|
|
103
|
+
if (!parsedRequest.success) {
|
|
104
|
+
return jsonError('Invalid Cortex AI SEO metadata request.', 400);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Sandbox installs have no server-side OpenRouter credential at all: the demo
|
|
108
|
+
// visitor pastes their own key into the browser, it lives in localStorage under
|
|
109
|
+
// `cortex_ai_sandbox_openrouter_api_key`, and it rides along on these headers.
|
|
110
|
+
// The env guard matters as much as the headers do — outside a sandbox the
|
|
111
|
+
// server must never accept a caller-supplied credential, because that would let
|
|
112
|
+
// any authenticated writer redirect generation through a key the operator never
|
|
113
|
+
// chose.
|
|
114
|
+
const sandboxKey =
|
|
115
|
+
process.env.NEXT_PUBLIC_IS_SANDBOX === 'true'
|
|
116
|
+
? request.headers.get('x-sandbox-openrouter-key')
|
|
117
|
+
: null;
|
|
118
|
+
const sandboxModelRaw =
|
|
119
|
+
process.env.NEXT_PUBLIC_IS_SANDBOX === 'true'
|
|
120
|
+
? request.headers.get('x-sandbox-openrouter-model')
|
|
121
|
+
: null;
|
|
122
|
+
|
|
123
|
+
let modelSelection = null;
|
|
124
|
+
if (sandboxModelRaw) {
|
|
125
|
+
try {
|
|
126
|
+
modelSelection = safeParseCortexAiModelSelection(JSON.parse(sandboxModelRaw));
|
|
127
|
+
} catch {
|
|
128
|
+
// Ignore malformed sandbox model headers.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// `generateCortexAiSeoMetadata` accepts a requested model id rather than a
|
|
133
|
+
// whole stored selection; the header is still parsed through
|
|
134
|
+
// `safeParseCortexAiModelSelection` so that a malformed value is discarded
|
|
135
|
+
// instead of being forwarded as a bogus id. Note that the ordinary text routing
|
|
136
|
+
// policy will IGNORE the requested id whenever the credential came from the
|
|
137
|
+
// environment, which is exactly the intended behaviour here: a model choice is
|
|
138
|
+
// only ever honoured when it arrives with the key that will pay for it.
|
|
139
|
+
const result = await generateCortexAiSeoMetadata({
|
|
140
|
+
apiKey: sandboxKey || undefined,
|
|
141
|
+
content: parsedRequest.data.content,
|
|
142
|
+
focusKeyword: parsedRequest.data.focusKeyword,
|
|
143
|
+
locale: parsedRequest.data.locale,
|
|
144
|
+
modelId: sandboxKey && modelSelection ? modelSelection.modelId : undefined,
|
|
145
|
+
siteTitle: parsedRequest.data.siteTitle,
|
|
146
|
+
title: parsedRequest.data.title,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return NextResponse.json(
|
|
150
|
+
{
|
|
151
|
+
credentialSource: result.credentialSource,
|
|
152
|
+
metaDescription: result.metaDescription,
|
|
153
|
+
metaTitle: result.metaTitle,
|
|
154
|
+
modelId: result.modelId,
|
|
155
|
+
ogDescription: result.ogDescription,
|
|
156
|
+
ogTitle: result.ogTitle,
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
headers: {
|
|
160
|
+
'x-cortex-ai-credential-source': result.credentialSource,
|
|
161
|
+
'x-cortex-ai-model': result.modelId,
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
// A CortexAiRoutingError is recognised STRUCTURALLY rather than with
|
|
167
|
+
// `instanceof`. The class is defined in @nextblock-cms/cortex, which resolves to
|
|
168
|
+
// library source inside this monorepo but to a published package in a
|
|
169
|
+
// standalone install; when both shapes end up in one bundle the two class
|
|
170
|
+
// identities are not the same object and `instanceof` silently returns false,
|
|
171
|
+
// swallowing the per-model attempt log that is the only useful diagnostic for a
|
|
172
|
+
// routing failure.
|
|
173
|
+
if (error && typeof error === 'object' && 'attempts' in error) {
|
|
174
|
+
console.error(
|
|
175
|
+
'[Cortex AI] Failed to generate SEO metadata after model attempts:',
|
|
176
|
+
JSON.stringify((error as { attempts: unknown }).attempts, null, 2)
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
console.error('[Cortex AI] Failed to generate SEO metadata:', error);
|
|
181
|
+
return jsonError(
|
|
182
|
+
summarizeCortexAiRoutingError(error, 'Failed to generate SEO metadata.'),
|
|
183
|
+
500
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -8028,6 +8028,196 @@ UPDATE public.site_settings
|
|
|
8028
8028
|
AND lower(value->>'contactEmail') LIKE '%@example.%';
|
|
8029
8029
|
|
|
8030
8030
|
|
|
8031
|
+
-- >>> FROM: 00000000000030_seo_redirects_and_robots.sql <<<
|
|
8032
|
+
-- SEO engine: managed 301/302 redirects and operator-configurable robots directives.
|
|
8033
|
+
--
|
|
8034
|
+
-- Two unrelated-looking things ship in one migration because they are the same
|
|
8035
|
+
-- feature from an operator's point of view: the \`/cms/settings/seo\` screen is where
|
|
8036
|
+
-- someone goes to say "this URL moved" and "do not crawl that". Splitting them
|
|
8037
|
+
-- across two migrations would only mean two files that must always be applied
|
|
8038
|
+
-- together.
|
|
8039
|
+
--
|
|
8040
|
+
-- WHY A TABLE AND NOT next.config.js redirects(). Redirects are content, not
|
|
8041
|
+
-- configuration. An editor who renames a page's slug needs the old URL to keep
|
|
8042
|
+
-- working immediately, without a redeploy and without touching source control.
|
|
8043
|
+
-- That rules out the build-time array; it has to be data.
|
|
8044
|
+
--
|
|
8045
|
+
-- WHY THE PROXY READS THIS WITH THE ANON KEY. apps/nextblock/proxy.ts (Next 16's
|
|
8046
|
+
-- renamed middleware) resolves redirects before rendering, and it holds a Supabase
|
|
8047
|
+
-- client built from the anon key. So the public SELECT policy below is load-bearing:
|
|
8048
|
+
-- without an explicit \`TO authenticated, anon\` grant the proxy's lookup would return
|
|
8049
|
+
-- zero rows for every anonymous visitor -- silently, with no error -- and no redirect
|
|
8050
|
+
-- would ever fire. Only is_active rows are exposed, so a half-written rule is never
|
|
8051
|
+
-- live.
|
|
8052
|
+
--
|
|
8053
|
+
-- WHY status_code IS AN integer AND NOT AN ENUM. The same reasoning migration 27
|
|
8054
|
+
-- recorded for \`source\`: a Postgres enum cannot be extended and used inside the same
|
|
8055
|
+
-- transaction, which is exactly the scope of one migration file. A CHECK constraint
|
|
8056
|
+
-- is replaceable in a single statement. 301 and 302 are the only two values the
|
|
8057
|
+
-- admin UI offers; 307/308 are deliberately not exposed, because their
|
|
8058
|
+
-- method-preserving semantics surprise operators who just want "this page moved".
|
|
8059
|
+
--
|
|
8060
|
+
-- LOOP SAFETY is enforced in application code (wouldCreateLoop in
|
|
8061
|
+
-- @nextblock-cms/utils, called by the admin server actions) rather than by a
|
|
8062
|
+
-- constraint, because detecting a cycle requires walking the whole table and a CHECK
|
|
8063
|
+
-- constraint can only see one row. The self-redirect case IS cheap to check per row,
|
|
8064
|
+
-- so that one is a constraint -- it is the cycle operators actually hit.
|
|
8065
|
+
--
|
|
8066
|
+
-- SECURITY POSTURE. A redirect can send every visitor of a path to an arbitrary
|
|
8067
|
+
-- external origin, which makes this table an open-redirect surface and a phishing
|
|
8068
|
+
-- lever. Writes are therefore ADMIN-only -- WRITER is deliberately excluded, matching
|
|
8069
|
+
-- site_scripts rather than the content tables. Reads are public but limited to active
|
|
8070
|
+
-- rows. The robots settings live in site_settings, whose existing read policy is
|
|
8071
|
+
-- already public for non-secret keys; nothing here is secret.
|
|
8072
|
+
--
|
|
8073
|
+
-- Forward-only and idempotent.
|
|
8074
|
+
|
|
8075
|
+
CREATE TABLE IF NOT EXISTS public.cms_redirects (
|
|
8076
|
+
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
|
8077
|
+
-- The incoming site-relative path to match, normalized by the application to a
|
|
8078
|
+
-- leading slash with no trailing slash (except root). Matching is exact: prefix
|
|
8079
|
+
-- and wildcard rules are deliberately not supported, because they are the usual
|
|
8080
|
+
-- way an operator builds an accidental loop.
|
|
8081
|
+
source_path text NOT NULL,
|
|
8082
|
+
-- Where to send the visitor. Either another site-relative path or a fully
|
|
8083
|
+
-- qualified https URL for an off-site move.
|
|
8084
|
+
destination_path text NOT NULL,
|
|
8085
|
+
-- 301 permanent (the SEO-meaningful one: search engines transfer ranking signals
|
|
8086
|
+
-- and browsers cache it aggressively) or 302 temporary.
|
|
8087
|
+
status_code integer DEFAULT 301 NOT NULL,
|
|
8088
|
+
is_active boolean DEFAULT true NOT NULL,
|
|
8089
|
+
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
8090
|
+
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
8091
|
+
CONSTRAINT cms_redirects_pkey PRIMARY KEY (id),
|
|
8092
|
+
-- One rule per source. This UNIQUE constraint also provides the index the proxy
|
|
8093
|
+
-- lookup relies on, so no separate plain index on source_path is needed.
|
|
8094
|
+
CONSTRAINT cms_redirects_source_path_key UNIQUE (source_path),
|
|
8095
|
+
CONSTRAINT cms_redirects_status_code_check
|
|
8096
|
+
CHECK ((status_code = ANY (ARRAY[301, 302]))),
|
|
8097
|
+
-- A source is always a path on this site; accepting an absolute URL here would
|
|
8098
|
+
-- silently never match, since the proxy only ever compares pathnames.
|
|
8099
|
+
CONSTRAINT cms_redirects_source_path_check
|
|
8100
|
+
CHECK ((source_path ~ '^/')),
|
|
8101
|
+
-- A destination is either a site-relative path or an https URL. Plain http is
|
|
8102
|
+
-- refused so a redirect can never downgrade a visitor to cleartext.
|
|
8103
|
+
CONSTRAINT cms_redirects_destination_path_check
|
|
8104
|
+
CHECK (((destination_path ~ '^/') OR (destination_path ~ '^https://'))),
|
|
8105
|
+
-- The one cycle a single row can express, and the one operators actually create.
|
|
8106
|
+
CONSTRAINT cms_redirects_no_self_redirect_check
|
|
8107
|
+
CHECK ((source_path <> destination_path))
|
|
8108
|
+
);
|
|
8109
|
+
|
|
8110
|
+
COMMENT ON TABLE public.cms_redirects IS
|
|
8111
|
+
'Operator-managed 301/302 redirects resolved by apps/nextblock/proxy.ts before rendering. Only is_active rows are publicly readable; only ADMIN may write, because a redirect rule is an open-redirect surface.';
|
|
8112
|
+
COMMENT ON COLUMN public.cms_redirects.source_path IS
|
|
8113
|
+
'Exact site-relative path to match, normalized to a leading slash and no trailing slash (except root). No wildcards, by design.';
|
|
8114
|
+
COMMENT ON COLUMN public.cms_redirects.destination_path IS
|
|
8115
|
+
'Site-relative path or absolute https URL to send the visitor to.';
|
|
8116
|
+
COMMENT ON COLUMN public.cms_redirects.status_code IS
|
|
8117
|
+
'301 permanent or 302 temporary. 307/308 are not offered by the admin UI.';
|
|
8118
|
+
COMMENT ON COLUMN public.cms_redirects.is_active IS
|
|
8119
|
+
'Only active rows are readable by anon, and only active rows are matched by the proxy.';
|
|
8120
|
+
|
|
8121
|
+
-- The proxy loads the whole active set once per cache window rather than querying per
|
|
8122
|
+
-- request, so the hot query is "all active rows" and this partial index is what serves
|
|
8123
|
+
-- it. Carrying source_path in the index keeps that read index-only.
|
|
8124
|
+
CREATE INDEX IF NOT EXISTS cms_redirects_active_source_idx
|
|
8125
|
+
ON public.cms_redirects USING btree (source_path) WHERE (is_active);
|
|
8126
|
+
|
|
8127
|
+
DROP TRIGGER IF EXISTS set_cms_redirects_updated_at ON public.cms_redirects;
|
|
8128
|
+
CREATE TRIGGER set_cms_redirects_updated_at
|
|
8129
|
+
BEFORE UPDATE ON public.cms_redirects
|
|
8130
|
+
FOR EACH ROW EXECUTE FUNCTION public.set_current_timestamp_updated_at();
|
|
8131
|
+
|
|
8132
|
+
ALTER TABLE public.cms_redirects ENABLE ROW LEVEL SECURITY;
|
|
8133
|
+
|
|
8134
|
+
GRANT ALL ON TABLE public.cms_redirects TO anon;
|
|
8135
|
+
GRANT ALL ON TABLE public.cms_redirects TO authenticated;
|
|
8136
|
+
GRANT ALL ON TABLE public.cms_redirects TO service_role;
|
|
8137
|
+
|
|
8138
|
+
-- The proxy runs as anon for a logged-out visitor, which is the overwhelming majority
|
|
8139
|
+
-- of traffic and the only traffic redirects really matter for. Without this policy the
|
|
8140
|
+
-- lookup returns zero rows and the feature is silently dead.
|
|
8141
|
+
DROP POLICY IF EXISTS "Public read active redirects" ON public.cms_redirects;
|
|
8142
|
+
CREATE POLICY "Public read active redirects" ON public.cms_redirects
|
|
8143
|
+
FOR SELECT TO authenticated, anon USING (is_active);
|
|
8144
|
+
|
|
8145
|
+
DROP POLICY IF EXISTS "Admins read all redirects" ON public.cms_redirects;
|
|
8146
|
+
CREATE POLICY "Admins read all redirects" ON public.cms_redirects
|
|
8147
|
+
FOR SELECT TO authenticated
|
|
8148
|
+
USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
|
|
8149
|
+
|
|
8150
|
+
DROP POLICY IF EXISTS "Admins insert redirects" ON public.cms_redirects;
|
|
8151
|
+
CREATE POLICY "Admins insert redirects" ON public.cms_redirects
|
|
8152
|
+
FOR INSERT TO authenticated
|
|
8153
|
+
WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
|
|
8154
|
+
|
|
8155
|
+
DROP POLICY IF EXISTS "Admins update redirects" ON public.cms_redirects;
|
|
8156
|
+
CREATE POLICY "Admins update redirects" ON public.cms_redirects
|
|
8157
|
+
FOR UPDATE TO authenticated
|
|
8158
|
+
USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role))
|
|
8159
|
+
WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
|
|
8160
|
+
|
|
8161
|
+
DROP POLICY IF EXISTS "Admins delete redirects" ON public.cms_redirects;
|
|
8162
|
+
CREATE POLICY "Admins delete redirects" ON public.cms_redirects
|
|
8163
|
+
FOR DELETE TO authenticated
|
|
8164
|
+
USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
|
|
8165
|
+
|
|
8166
|
+
-- Robots directives live in site_settings rather than a table of their own: there is
|
|
8167
|
+
-- exactly one robots.txt per install, so a dedicated table would only ever hold one
|
|
8168
|
+
-- row, and site_settings already carries the public-read / staff-write policy this
|
|
8169
|
+
-- needs. The stored shape matches \`RobotsSettings\` in @nextblock-cms/utils, and
|
|
8170
|
+
-- \`normalizeRobotsSettings\` tolerates a missing or malformed value -- so this seed is
|
|
8171
|
+
-- a convenience for the settings screen, not a correctness requirement for rendering.
|
|
8172
|
+
--
|
|
8173
|
+
-- ON CONFLICT DO NOTHING because an install that has already configured robots must
|
|
8174
|
+
-- not have its rules reset by a replay of this migration.
|
|
8175
|
+
INSERT INTO public.site_settings (key, value)
|
|
8176
|
+
VALUES (
|
|
8177
|
+
'seo_robots_settings',
|
|
8178
|
+
'{"customRules": "", "isIndexingEnabled": true, "sitemapEnabled": true, "userAgentRules": [{"allow": ["/"], "disallow": [], "userAgent": "*"}]}'::jsonb
|
|
8179
|
+
)
|
|
8180
|
+
ON CONFLICT (key) DO NOTHING;
|
|
8181
|
+
|
|
8182
|
+
|
|
8183
|
+
-- >>> FROM: 00000000000031_seo_robots_settings_admin_only.sql <<<
|
|
8184
|
+
-- Restrict writes to the robots.txt settings row to ADMIN only.
|
|
8185
|
+
--
|
|
8186
|
+
-- \`site_settings.seo_robots_settings\` is the row that decides whether the whole site
|
|
8187
|
+
-- is crawlable: app/robots.ts reads it on every /robots.txt hit and serves a blanket
|
|
8188
|
+
-- \`Disallow: /\` when \`isIndexingEnabled\` is false. The CMS only offers that switch
|
|
8189
|
+
-- under /cms/settings/seo, and \`saveRobotsSettings\` re-checks for ADMIN before it
|
|
8190
|
+
-- writes — but RLS is the independent boundary, and it did not agree. The baseline
|
|
8191
|
+
-- write policies let ADMIN *or* WRITER write any key outside the sensitive array, so
|
|
8192
|
+
-- a WRITER holding a normal session could PATCH this row through PostgREST and take
|
|
8193
|
+
-- the entire site out of Google. That failure is quiet (nothing in the CMS shows it),
|
|
8194
|
+
-- slow to notice (search traffic decays over weeks) and hard to attribute after the
|
|
8195
|
+
-- fact, which is why the database has to refuse it rather than trusting the one
|
|
8196
|
+
-- server action that happens to guard it today.
|
|
8197
|
+
--
|
|
8198
|
+
-- The SELECT policy is deliberately NOT touched. This key must stay anon-READABLE:
|
|
8199
|
+
-- app/robots.ts reads it with the anon (SSG) client on every crawl, and adding the key
|
|
8200
|
+
-- to the read policy's sensitive array would make robots.txt fall back to its
|
|
8201
|
+
-- permissive defaults for every crawler. Only INSERT/UPDATE/DELETE move to ADMIN-only,
|
|
8202
|
+
-- matching the UI boundary — exactly the shape migration 00000000000011 used for
|
|
8203
|
+
-- language_detection_settings, which is anon-readable for the same reason.
|
|
8204
|
+
--
|
|
8205
|
+
-- Every key already present in each policy's array is preserved (dropping one would
|
|
8206
|
+
-- silently widen write access back to WRITER for that key); \`seo_robots_settings\` is
|
|
8207
|
+
-- appended to the three write policies only.
|
|
8208
|
+
|
|
8209
|
+
DROP POLICY IF EXISTS site_settings_insert_policy ON public.site_settings;
|
|
8210
|
+
CREATE POLICY site_settings_insert_policy ON public.site_settings FOR INSERT TO authenticated WITH CHECK ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
|
|
8211
|
+
|
|
8212
|
+
DROP POLICY IF EXISTS site_settings_update_policy ON public.site_settings;
|
|
8213
|
+
CREATE POLICY site_settings_update_policy ON public.site_settings FOR UPDATE TO authenticated USING ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role)))) WITH CHECK ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
|
|
8214
|
+
|
|
8215
|
+
DROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;
|
|
8216
|
+
CREATE POLICY site_settings_delete_policy ON public.site_settings FOR DELETE TO authenticated USING ((((key <> ALL (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = ANY (ARRAY['ADMIN'::public.user_role, 'WRITER'::public.user_role]))) OR ((key = ANY (ARRAY['cortex_ai_openrouter_api_key'::text, 'bot_protection_secret'::text, 'email_secret'::text, 'payment_secret'::text, 'language_detection_settings'::text, 'cortex_ai_pexels_api_key'::text, 'cortex_ai_unsplash_access_key'::text, 'cortex_ai_mcp_settings'::text, 'seo_robots_settings'::text])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
|
|
8217
|
+
|
|
8218
|
+
-- Forward-only and idempotent.
|
|
8219
|
+
|
|
8220
|
+
|
|
8031
8221
|
-- Step D: Record the applied migrations in history (truncated in Step B) so
|
|
8032
8222
|
-- \`npm run db:migrate:check\` reports up to date instead of listing every file as pending.
|
|
8033
8223
|
INSERT INTO supabase_migrations.schema_migrations (version, name) VALUES
|
|
@@ -8060,7 +8250,9 @@ UPDATE public.site_settings
|
|
|
8060
8250
|
('00000000000026', 'product_inquiries'),
|
|
8061
8251
|
('00000000000027', 'message_threads'),
|
|
8062
8252
|
('00000000000028', 'interaction_replies'),
|
|
8063
|
-
('00000000000029', 'form_endpoints_default_empty')
|
|
8253
|
+
('00000000000029', 'form_endpoints_default_empty'),
|
|
8254
|
+
('00000000000030', 'seo_redirects_and_robots'),
|
|
8255
|
+
('00000000000031', 'seo_robots_settings_admin_only')
|
|
8064
8256
|
ON CONFLICT (version) DO NOTHING;
|
|
8065
8257
|
|
|
8066
8258
|
-- Step E: Anchor preserved profiles
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
LayoutDashboard, FileText, PenTool, Users, Settings, ChevronRight, LogOut, Menu, ListTree, Image as ImageIconLucide, X, Languages as LanguagesIconLucide, MessageSquare,
|
|
10
10
|
Copyright as CopyrightIcon, ShoppingBag, ListOrdered, CreditCard, Package, Coins, MessageSquareText,
|
|
11
11
|
ExternalLink, Paintbrush, Brain, TicketPercent, ShieldAlert, Folder, DatabaseBackup, Boxes, Tag,
|
|
12
|
-
ShieldCheck, Code2, Cookie, LineChart, Mail, UserPlus, SlidersHorizontal,
|
|
12
|
+
ShieldCheck, Code2, Cookie, LineChart, Mail, UserPlus, SlidersHorizontal, Search,
|
|
13
13
|
} from "lucide-react"
|
|
14
14
|
import TwoFactorReminderBanner from "./components/TwoFactorReminderBanner"
|
|
15
15
|
import SystemAlertsBanner, { type SystemAlertItem } from "./components/SystemAlertsBanner"
|
|
@@ -249,6 +249,9 @@ export default function CmsClientLayout({
|
|
|
249
249
|
else if (pathname.startsWith("/cms/settings/copyright")) pageTitle = "Copyright Settings";
|
|
250
250
|
else if (pathname.startsWith("/cms/settings/global-css")) pageTitle = "Themes & CSS";
|
|
251
251
|
else if (pathname.startsWith("/cms/settings/site-scripts")) pageTitle = "Site Scripts";
|
|
252
|
+
// Must stay ABOVE the generic "/cms/settings" arm below: this chain is
|
|
253
|
+
// first-match-wins, so anything placed after that fallback is dead code.
|
|
254
|
+
else if (pathname.startsWith("/cms/settings/seo")) pageTitle = "SEO & Redirects";
|
|
252
255
|
else if (pathname.startsWith("/cms/settings/extra-translations")) pageTitle = "Extra Translations";
|
|
253
256
|
else if (pathname.startsWith("/cms/settings/backup-restore")) pageTitle = "Backup And Restore";
|
|
254
257
|
else if (pathname.startsWith("/cms/settings/currencies")) pageTitle = "Currency Settings";
|
|
@@ -455,6 +458,9 @@ export default function CmsClientLayout({
|
|
|
455
458
|
<NavItem href="/cms/settings/google-analytics" icon={LineChart} isActive={pathname.startsWith("/cms/settings/google-analytics")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
|
|
456
459
|
Google Analytics
|
|
457
460
|
</NavItem>
|
|
461
|
+
<NavItem href="/cms/settings/seo" icon={Search} isActive={pathname.startsWith("/cms/settings/seo")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
|
|
462
|
+
SEO & Redirects
|
|
463
|
+
</NavItem>
|
|
458
464
|
<NavItem href="/cms/settings/extra-translations" icon={MessageSquare} isActive={pathname.startsWith("/cms/settings/extra-translations")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
|
|
459
465
|
Extra Translations
|
|
460
466
|
</NavItem>
|