create-nextblock 0.14.5 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/nextblock-template/AGENTS.md +9 -0
- package/templates/nextblock-template/CLAUDE.md +1 -0
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +285 -1
- package/templates/nextblock-template/app/api/mcp/route.ts +415 -0
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +5 -1
- package/templates/nextblock-template/app/cms/media/import-external-image.ts +97 -11
- package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +584 -0
- package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +6 -0
- package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +4 -20
- package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +205 -0
- package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +64 -1
- package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -0
- package/templates/nextblock-template/app/cms/settings/site-scripts/actions.ts +346 -0
- package/templates/nextblock-template/app/cms/settings/site-scripts/components/SiteScriptManager.tsx +492 -0
- package/templates/nextblock-template/app/cms/settings/site-scripts/page.tsx +51 -0
- package/templates/nextblock-template/app/layout.tsx +33 -0
- package/templates/nextblock-template/components/BlockRenderer.tsx +9 -3
- package/templates/nextblock-template/components/SiteScripts.tsx +56 -0
- package/templates/nextblock-template/components/blocks/renderers/TextBlockRenderer.tsx +1 -9
- package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +31 -1
- package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +272 -0
- package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +5 -0
- package/templates/nextblock-template/lib/site-scripts/revisions.ts +71 -0
- package/templates/nextblock-template/lib/site-scripts/types.ts +46 -0
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { revalidatePath } from 'next/cache';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createClient,
|
|
5
|
+
getServiceRoleSupabaseClient,
|
|
6
|
+
verifyPackageOnline,
|
|
7
|
+
} from '@nextblock-cms/db/server';
|
|
8
|
+
import {
|
|
9
|
+
CORTEX_AI_PACKAGE_ID,
|
|
10
|
+
handleCortexMcpMessage,
|
|
11
|
+
isLocalhostHost,
|
|
12
|
+
parseBearerToken,
|
|
13
|
+
resolveCortexAiMcpSettings,
|
|
14
|
+
shouldTrustLocalMcpRequest,
|
|
15
|
+
touchCortexAiMcpToken,
|
|
16
|
+
verifyCortexAiMcpToken,
|
|
17
|
+
type CortexAiMcpScope,
|
|
18
|
+
type CortexMcpToolContext,
|
|
19
|
+
type JsonRpcMessage,
|
|
20
|
+
} from '@nextblock-cms/cortex';
|
|
21
|
+
|
|
22
|
+
import { validateBlockContent } from '../../../lib/blocks/blockRegistry';
|
|
23
|
+
import { importExternalImageToMedia } from '../../cms/media/import-external-image';
|
|
24
|
+
import { captureRevisionBaseline, commitRevisionFromBaseline } from '../../cms/revisions/service';
|
|
25
|
+
import type { AnyFullContent } from '../../cms/revisions/utils';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Model Context Protocol server endpoint.
|
|
29
|
+
*
|
|
30
|
+
* Exposes the Cortex AI tool registry over MCP Streamable HTTP so external clients
|
|
31
|
+
* (Claude Code, Claude Desktop, Cursor, VS Code) can operate this CMS with the same
|
|
32
|
+
* typed, validated tools the in-app dashboard agent uses. The protocol itself lives
|
|
33
|
+
* in `@nextblock-cms/cortex` (`mcp-server.ts`); this file is the HTTP shim plus auth.
|
|
34
|
+
*
|
|
35
|
+
* Node runtime, not Edge: the tool executors reach `node:crypto`, `sharp` (via the
|
|
36
|
+
* media importer) and the service-role Supabase client.
|
|
37
|
+
*/
|
|
38
|
+
export const runtime = 'nodejs';
|
|
39
|
+
export const dynamic = 'force-dynamic';
|
|
40
|
+
|
|
41
|
+
const SERVER_VERSION = '1.0.0';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Confirmation is skipped for MCP callers, deliberately.
|
|
45
|
+
*
|
|
46
|
+
* The in-app agent's two-phase confirm works by matching a phrase in the user's *next
|
|
47
|
+
* chat message*, which has no analogue in MCP — the model calls a tool and gets a
|
|
48
|
+
* result, with no channel to carry a human phrase back. Every MCP host already gates
|
|
49
|
+
* tool calls behind its own approval UI, so the confirmation would be a second prompt
|
|
50
|
+
* the protocol cannot satisfy, and leaving it on would simply make every mutating
|
|
51
|
+
* tool return a preview forever. The real control for MCP is the token scope: a
|
|
52
|
+
* read-only token never sees a mutating tool at all.
|
|
53
|
+
*/
|
|
54
|
+
const MCP_SKIP_CONFIRMATION = true;
|
|
55
|
+
|
|
56
|
+
type McpAuth = {
|
|
57
|
+
actorUserId: string | null;
|
|
58
|
+
/**
|
|
59
|
+
* True when this token outlived the account that minted it (`created_by` is
|
|
60
|
+
* `ON DELETE SET NULL`), so `actorUserId` below is a stand-in rather than the
|
|
61
|
+
* principal that actually holds the credential.
|
|
62
|
+
*
|
|
63
|
+
* A stand-in is fine for *attribution* — a revision needs some author — but it
|
|
64
|
+
* must never be the basis for *authorization*, or deleting an administrator would
|
|
65
|
+
* silently promote their leftover token to whichever admin happens to sort first.
|
|
66
|
+
* Offboarding someone is exactly when their credentials should lose power, not
|
|
67
|
+
* inherit someone else's.
|
|
68
|
+
*/
|
|
69
|
+
actorFromOrphanedToken: boolean;
|
|
70
|
+
scopes: CortexAiMcpScope[];
|
|
71
|
+
source: 'admin-session' | 'localhost' | 'token';
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* MCP has no cookie session, so the importer is handed the actor this request already
|
|
76
|
+
* authenticated. Without it every image import fails with "You must be signed in to
|
|
77
|
+
* import an image" — which silently strips the imagery out of any page or product
|
|
78
|
+
* built over MCP, since executors treat an import failure as non-fatal.
|
|
79
|
+
*/
|
|
80
|
+
function createMcpImageImporter(actorUserId: string | null) {
|
|
81
|
+
return async function importExternalImageForMcp(input: {
|
|
82
|
+
url: string;
|
|
83
|
+
altText?: string;
|
|
84
|
+
}): Promise<{ id: string } | { error: string }> {
|
|
85
|
+
const result = await importExternalImageToMedia({
|
|
86
|
+
...(actorUserId ? { actorUserId } : {}),
|
|
87
|
+
altText: input.altText,
|
|
88
|
+
url: input.url,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if ('error' in result) {
|
|
92
|
+
return { error: result.error };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { id: result.media.id };
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Mirrors the global-agent route so MCP writes land in Revision History like any other edit. */
|
|
100
|
+
function createMcpRevisionRecorder(authorId: string | null) {
|
|
101
|
+
return async function recordRevision(input: {
|
|
102
|
+
baseline?: unknown;
|
|
103
|
+
contentType: 'page' | 'post' | 'product';
|
|
104
|
+
entityId: number | string;
|
|
105
|
+
phase: 'capture' | 'commit';
|
|
106
|
+
}): Promise<unknown> {
|
|
107
|
+
if (input.phase === 'capture') {
|
|
108
|
+
return captureRevisionBaseline(input.contentType, input.entityId);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const result = await commitRevisionFromBaseline(
|
|
112
|
+
input.contentType,
|
|
113
|
+
input.entityId,
|
|
114
|
+
authorId,
|
|
115
|
+
(input.baseline ?? null) as AnyFullContent | null
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
if ('error' in result) {
|
|
119
|
+
console.error('Cortex AI MCP: revision not recorded —', result.error);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return undefined;
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Reject cross-origin browser calls (DNS-rebinding defence, required by the spec).
|
|
128
|
+
*
|
|
129
|
+
* Only enforced when an `Origin` header is present: native MCP clients are not
|
|
130
|
+
* browsers and send none, so requiring one would lock out every real caller.
|
|
131
|
+
*/
|
|
132
|
+
function isOriginAllowed(request: Request): boolean {
|
|
133
|
+
const origin = request.headers.get('origin');
|
|
134
|
+
|
|
135
|
+
if (!origin) {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let originHost: string;
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
originHost = new URL(origin).host;
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (isLocalhostHost(originHost)) {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const host = request.headers.get('host');
|
|
152
|
+
|
|
153
|
+
if (host && originHost.toLowerCase() === host.toLowerCase()) {
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const configuredUrl = process.env.NEXT_PUBLIC_URL;
|
|
158
|
+
|
|
159
|
+
if (configuredUrl) {
|
|
160
|
+
try {
|
|
161
|
+
return new URL(configuredUrl).host.toLowerCase() === originHost.toLowerCase();
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Establish who is calling.
|
|
172
|
+
*
|
|
173
|
+
* Three accepted paths, in priority order:
|
|
174
|
+
* 1. A bearer token from `mcp_access_tokens` — the path every external client uses.
|
|
175
|
+
* 2. An authenticated ADMIN cookie session — lets the dashboard's own "Test
|
|
176
|
+
* connection" button reach the endpoint without minting a token first.
|
|
177
|
+
* 3. Loopback in development, when the operator has left that setting on.
|
|
178
|
+
*/
|
|
179
|
+
async function authenticateMcpRequest(request: Request): Promise<McpAuth | null> {
|
|
180
|
+
const serviceClient = getServiceRoleSupabaseClient();
|
|
181
|
+
const settings = await resolveCortexAiMcpSettings(serviceClient);
|
|
182
|
+
|
|
183
|
+
if (!settings.enabled) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const bearer = parseBearerToken(request.headers.get('authorization'));
|
|
188
|
+
|
|
189
|
+
if (bearer) {
|
|
190
|
+
const verification = await verifyCortexAiMcpToken(serviceClient, bearer);
|
|
191
|
+
|
|
192
|
+
if (!verification.valid) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Bookkeeping only — never block the call on it.
|
|
197
|
+
void touchCortexAiMcpToken(serviceClient, verification.token.id);
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
actorFromOrphanedToken: !verification.token.created_by,
|
|
201
|
+
actorUserId: verification.token.created_by ?? (await resolveFallbackAdminUserId()),
|
|
202
|
+
scopes: verification.scopes,
|
|
203
|
+
source: 'token',
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const adminUserId = await resolveAdminSessionUserId();
|
|
208
|
+
|
|
209
|
+
if (adminUserId) {
|
|
210
|
+
return {
|
|
211
|
+
actorFromOrphanedToken: false,
|
|
212
|
+
actorUserId: adminUserId,
|
|
213
|
+
scopes: ['read', 'write'],
|
|
214
|
+
source: 'admin-session',
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (shouldTrustLocalMcpRequest({ hostHeader: request.headers.get('host'), settings })) {
|
|
219
|
+
// Loopback trust is an explicit opt-in on a development machine, where anyone
|
|
220
|
+
// who can reach this endpoint can already read the service-role key out of
|
|
221
|
+
// .env.local. Not treated as orphaned: it grants nothing new.
|
|
222
|
+
return {
|
|
223
|
+
actorFromOrphanedToken: false,
|
|
224
|
+
actorUserId: await resolveFallbackAdminUserId(),
|
|
225
|
+
scopes: ['read', 'write'],
|
|
226
|
+
source: 'localhost',
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Every mutating Cortex executor calls `getActorUserId()` and throws without one, so a
|
|
235
|
+
* connection with no identity behind it can read but never write. Two connections have
|
|
236
|
+
* that problem: localhost trust (nobody signed in) and a token whose creator was since
|
|
237
|
+
* deleted (`created_by` is `ON DELETE SET NULL`).
|
|
238
|
+
*
|
|
239
|
+
* Rather than advertise a `write` scope those connections cannot actually use, fall back
|
|
240
|
+
* to an ADMIN profile so the write is attributed to a real person in Revision History.
|
|
241
|
+
* This grants no new authority — reaching here already required either loopback in
|
|
242
|
+
* development or a valid admin-minted token — it only supplies the author field.
|
|
243
|
+
*
|
|
244
|
+
* `id` ordering is arbitrary but stable, which is what matters: the same fallback admin
|
|
245
|
+
* every time, so revision authorship does not jump between people run to run.
|
|
246
|
+
*/
|
|
247
|
+
async function resolveFallbackAdminUserId(): Promise<string | null> {
|
|
248
|
+
try {
|
|
249
|
+
const { data } = await getServiceRoleSupabaseClient()
|
|
250
|
+
.from('profiles')
|
|
251
|
+
.select('id')
|
|
252
|
+
.eq('role', 'ADMIN')
|
|
253
|
+
.order('id', { ascending: true })
|
|
254
|
+
.limit(1)
|
|
255
|
+
.maybeSingle();
|
|
256
|
+
|
|
257
|
+
return data?.id ?? null;
|
|
258
|
+
} catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function resolveAdminSessionUserId(): Promise<string | null> {
|
|
264
|
+
try {
|
|
265
|
+
const supabase = createClient();
|
|
266
|
+
const {
|
|
267
|
+
data: { user },
|
|
268
|
+
} = await supabase.auth.getUser();
|
|
269
|
+
|
|
270
|
+
if (!user) {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const { data: profile } = await supabase
|
|
275
|
+
.from('profiles')
|
|
276
|
+
.select('role')
|
|
277
|
+
.eq('id', user.id)
|
|
278
|
+
.single();
|
|
279
|
+
|
|
280
|
+
return profile?.role === 'ADMIN' ? user.id : null;
|
|
281
|
+
} catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function buildToolContext(auth: McpAuth): CortexMcpToolContext {
|
|
287
|
+
return {
|
|
288
|
+
actorFromOrphanedToken: auth.actorFromOrphanedToken,
|
|
289
|
+
actorUserId: auth.actorUserId,
|
|
290
|
+
importExternalImage: createMcpImageImporter(auth.actorUserId),
|
|
291
|
+
// No open editor over MCP: tools that need a target take it in their arguments
|
|
292
|
+
// (`cmsTarget`, `slug`, `entityId`) rather than inheriting one from a UI.
|
|
293
|
+
pageContext: null,
|
|
294
|
+
recordRevision: createMcpRevisionRecorder(auth.actorUserId),
|
|
295
|
+
revalidatePath,
|
|
296
|
+
skipConfirmation: MCP_SKIP_CONFIRMATION,
|
|
297
|
+
supabase: getServiceRoleSupabaseClient(),
|
|
298
|
+
validateBlockContent,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const JSON_HEADERS = {
|
|
303
|
+
'Cache-Control': 'no-store',
|
|
304
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
305
|
+
} as const;
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 401 for an unauthenticated caller.
|
|
309
|
+
*
|
|
310
|
+
* The `WWW-Authenticate` value is intentionally bare. Adding a `resource_metadata`
|
|
311
|
+
* parameter would advertise RFC 9728 OAuth discovery, and Claude Code responds to
|
|
312
|
+
* that by starting an OAuth flow — which dead-ends against a static-token server.
|
|
313
|
+
* A plain challenge tells the client "send a bearer token" and nothing more.
|
|
314
|
+
*/
|
|
315
|
+
function unauthorized(message: string): Response {
|
|
316
|
+
return new Response(JSON.stringify({ error: message }), {
|
|
317
|
+
headers: {
|
|
318
|
+
...JSON_HEADERS,
|
|
319
|
+
'WWW-Authenticate': 'Bearer realm="NextBlock Cortex AI MCP"',
|
|
320
|
+
},
|
|
321
|
+
status: 401,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export async function POST(request: Request): Promise<Response> {
|
|
326
|
+
if (!isOriginAllowed(request)) {
|
|
327
|
+
return new Response(JSON.stringify({ error: 'Origin not allowed.' }), {
|
|
328
|
+
headers: JSON_HEADERS,
|
|
329
|
+
status: 403,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const isCortexAiActive = await verifyPackageOnline(CORTEX_AI_PACKAGE_ID);
|
|
334
|
+
|
|
335
|
+
if (!isCortexAiActive) {
|
|
336
|
+
return new Response(
|
|
337
|
+
JSON.stringify({ error: 'NextBlock Cortex AI is not active for this workspace.' }),
|
|
338
|
+
{ headers: JSON_HEADERS, status: 403 }
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const auth = await authenticateMcpRequest(request);
|
|
343
|
+
|
|
344
|
+
if (!auth) {
|
|
345
|
+
return unauthorized(
|
|
346
|
+
'A valid NextBlock MCP access token is required. Generate one in CMS Settings → Cortex AI, and confirm the MCP server is enabled there.'
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
let message: JsonRpcMessage;
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
message = (await request.json()) as JsonRpcMessage;
|
|
354
|
+
} catch {
|
|
355
|
+
return new Response(
|
|
356
|
+
JSON.stringify({
|
|
357
|
+
error: { code: -32700, message: 'Parse error: request body is not valid JSON.' },
|
|
358
|
+
id: null,
|
|
359
|
+
jsonrpc: '2.0',
|
|
360
|
+
}),
|
|
361
|
+
{ headers: JSON_HEADERS, status: 400 }
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const response = await handleCortexMcpMessage(message, {
|
|
366
|
+
context: buildToolContext(auth),
|
|
367
|
+
scopes: auth.scopes,
|
|
368
|
+
serverVersion: SERVER_VERSION,
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Notifications and responses: 202 Accepted with no body. Returning a JSON-RPC
|
|
372
|
+
// envelope for a message that carried no `id` desyncs strict clients.
|
|
373
|
+
if (response.body === null) {
|
|
374
|
+
return new Response(null, { status: response.status });
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return new Response(JSON.stringify(response.body), {
|
|
378
|
+
headers: JSON_HEADERS,
|
|
379
|
+
status: response.status,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The optional server→client SSE stream.
|
|
385
|
+
*
|
|
386
|
+
* This server never initiates requests or pushes unsolicited notifications — every
|
|
387
|
+
* response is returned inline on the POST — so there is nothing to stream. The spec
|
|
388
|
+
* explicitly permits answering the GET with 405 in that case.
|
|
389
|
+
*/
|
|
390
|
+
export function GET(): Response {
|
|
391
|
+
return new Response(
|
|
392
|
+
JSON.stringify({
|
|
393
|
+
error:
|
|
394
|
+
'This MCP endpoint does not offer a server-initiated SSE stream. Send JSON-RPC messages via POST.',
|
|
395
|
+
}),
|
|
396
|
+
{ headers: { ...JSON_HEADERS, Allow: 'POST, DELETE, OPTIONS' }, status: 405 }
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Session termination. The server is stateless, so there is no session to tear down. */
|
|
401
|
+
export function DELETE(): Response {
|
|
402
|
+
return new Response(null, { status: 204 });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function OPTIONS(): Response {
|
|
406
|
+
return new Response(null, {
|
|
407
|
+
headers: {
|
|
408
|
+
'Access-Control-Allow-Headers':
|
|
409
|
+
'Authorization, Content-Type, MCP-Protocol-Version, Mcp-Session-Id, Mcp-Method, Mcp-Name',
|
|
410
|
+
'Access-Control-Allow-Methods': 'POST, DELETE, OPTIONS',
|
|
411
|
+
Allow: 'POST, DELETE, OPTIONS',
|
|
412
|
+
},
|
|
413
|
+
status: 204,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
@@ -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,
|
|
11
11
|
ExternalLink, Paintbrush, Brain, TicketPercent, ShieldAlert, Folder, DatabaseBackup, Boxes, Tag,
|
|
12
|
-
ShieldCheck, Cookie, LineChart, Mail, UserPlus, SlidersHorizontal,
|
|
12
|
+
ShieldCheck, Code2, Cookie, LineChart, Mail, UserPlus, SlidersHorizontal,
|
|
13
13
|
} from "lucide-react"
|
|
14
14
|
import TwoFactorReminderBanner from "./components/TwoFactorReminderBanner"
|
|
15
15
|
import SystemAlertsBanner, { type SystemAlertItem } from "./components/SystemAlertsBanner"
|
|
@@ -229,6 +229,7 @@ export default function CmsClientLayout({
|
|
|
229
229
|
else if (pathname.startsWith("/cms/settings/logos")) pageTitle = "Branding";
|
|
230
230
|
else if (pathname.startsWith("/cms/settings/copyright")) pageTitle = "Copyright Settings";
|
|
231
231
|
else if (pathname.startsWith("/cms/settings/global-css")) pageTitle = "Themes & CSS";
|
|
232
|
+
else if (pathname.startsWith("/cms/settings/site-scripts")) pageTitle = "Site Scripts";
|
|
232
233
|
else if (pathname.startsWith("/cms/settings/extra-translations")) pageTitle = "Extra Translations";
|
|
233
234
|
else if (pathname.startsWith("/cms/settings/backup-restore")) pageTitle = "Backup And Restore";
|
|
234
235
|
else if (pathname.startsWith("/cms/settings/currencies")) pageTitle = "Currency Settings";
|
|
@@ -424,6 +425,9 @@ export default function CmsClientLayout({
|
|
|
424
425
|
<NavItem href="/cms/settings/global-css" icon={Paintbrush} isActive={pathname.startsWith("/cms/settings/global-css")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
|
|
425
426
|
Themes & CSS
|
|
426
427
|
</NavItem>
|
|
428
|
+
<NavItem href="/cms/settings/site-scripts" icon={Code2} isActive={pathname.startsWith("/cms/settings/site-scripts")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
|
|
429
|
+
Site Scripts
|
|
430
|
+
</NavItem>
|
|
427
431
|
<NavItem href="/cms/settings/privacy" icon={Cookie} isActive={pathname.startsWith("/cms/settings/privacy")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
|
|
428
432
|
Privacy & Consent
|
|
429
433
|
</NavItem>
|
|
@@ -6,7 +6,7 @@ import "server-only";
|
|
|
6
6
|
import sharp from "sharp";
|
|
7
7
|
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
|
8
8
|
|
|
9
|
-
import { createClient } from "@nextblock-cms/db/server";
|
|
9
|
+
import { createClient, getServiceRoleSupabaseClient } from "@nextblock-cms/db/server";
|
|
10
10
|
import { recordMediaUpload } from "@nextblock-cms/db";
|
|
11
11
|
import { getS3Client } from "@nextblock-cms/utils/server";
|
|
12
12
|
|
|
@@ -36,6 +36,34 @@ type ImportExternalImageResult =
|
|
|
36
36
|
* Reject local/loopback/private/link-local hosts and cloud metadata endpoints so an
|
|
37
37
|
* admin-supplied URL cannot be used to probe internal infrastructure (SSRF).
|
|
38
38
|
*/
|
|
39
|
+
/**
|
|
40
|
+
* Unwrap an IPv4-mapped IPv6 address to dotted-quad.
|
|
41
|
+
*
|
|
42
|
+
* `http://[::ffff:127.0.0.1]/` reaches loopback, but the URL parser normalises it to
|
|
43
|
+
* `::ffff:7f00:1`, which matches none of the IPv4 checks below — a working bypass of
|
|
44
|
+
* the whole blocklist. Mirrors `unwrapMappedIpv4` in
|
|
45
|
+
* libs/cortex/src/lib/ai-global-agent-tools.ts; the two blocklists are duplicated
|
|
46
|
+
* because a published lib cannot import from the app, so fix both together.
|
|
47
|
+
*/
|
|
48
|
+
function unwrapMappedIpv4(host: string): string | null {
|
|
49
|
+
const mapped = host.match(/^::ffff:(.+)$/i);
|
|
50
|
+
|
|
51
|
+
if (!mapped) return null;
|
|
52
|
+
|
|
53
|
+
const rest = mapped[1] as string;
|
|
54
|
+
|
|
55
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(rest)) return rest;
|
|
56
|
+
|
|
57
|
+
const hextets = rest.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
|
|
58
|
+
|
|
59
|
+
if (!hextets) return null;
|
|
60
|
+
|
|
61
|
+
const high = Number.parseInt(hextets[1] as string, 16);
|
|
62
|
+
const low = Number.parseInt(hextets[2] as string, 16);
|
|
63
|
+
|
|
64
|
+
return [(high >> 8) & 255, high & 255, (low >> 8) & 255, low & 255].join(".");
|
|
65
|
+
}
|
|
66
|
+
|
|
39
67
|
function isBlockedImportHost(hostname: string): boolean {
|
|
40
68
|
const host = hostname.trim().toLowerCase().replace(/\.$/, "").replace(/^\[|\]$/g, "");
|
|
41
69
|
|
|
@@ -50,10 +78,23 @@ function isBlockedImportHost(hostname: string): boolean {
|
|
|
50
78
|
return true;
|
|
51
79
|
}
|
|
52
80
|
|
|
53
|
-
if (
|
|
81
|
+
if (
|
|
82
|
+
host === "0.0.0.0" ||
|
|
83
|
+
host === "::" ||
|
|
84
|
+
host === "::1" ||
|
|
85
|
+
host.startsWith("fe80:") ||
|
|
86
|
+
host.startsWith("fc") ||
|
|
87
|
+
host.startsWith("fd")
|
|
88
|
+
) {
|
|
54
89
|
return true;
|
|
55
90
|
}
|
|
56
91
|
|
|
92
|
+
const mappedIpv4 = unwrapMappedIpv4(host);
|
|
93
|
+
|
|
94
|
+
if (mappedIpv4) {
|
|
95
|
+
return isBlockedImportHost(mappedIpv4);
|
|
96
|
+
}
|
|
97
|
+
|
|
57
98
|
const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
58
99
|
|
|
59
100
|
if (ipv4) {
|
|
@@ -93,15 +134,28 @@ function slugifyFileBase(value: string): string {
|
|
|
93
134
|
}
|
|
94
135
|
|
|
95
136
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
137
|
+
* Establish the ADMIN/WRITER this import is attributed to.
|
|
138
|
+
*
|
|
139
|
+
* Two paths: a cookie session (the dashboard) or an explicitly supplied actor (the
|
|
140
|
+
* MCP server, which has already authenticated the caller by bearer token). Both
|
|
141
|
+
* end at the same role check, so the second is a different way to *identify* the
|
|
142
|
+
* uploader, not a way to skip authorization.
|
|
99
143
|
*/
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
144
|
+
async function resolveImportActorId(actorUserId?: string): Promise<{ id: string } | { error: string }> {
|
|
145
|
+
if (actorUserId) {
|
|
146
|
+
const { data: profile } = await getServiceRoleSupabaseClient()
|
|
147
|
+
.from("profiles")
|
|
148
|
+
.select("role")
|
|
149
|
+
.eq("id", actorUserId)
|
|
150
|
+
.single();
|
|
151
|
+
|
|
152
|
+
if (!profile || !["ADMIN", "WRITER"].includes(profile.role)) {
|
|
153
|
+
return { error: "You do not have permission to import media." };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { id: actorUserId };
|
|
157
|
+
}
|
|
158
|
+
|
|
105
159
|
const supabase = createClient();
|
|
106
160
|
const {
|
|
107
161
|
data: { user },
|
|
@@ -117,6 +171,34 @@ export async function importExternalImageToMedia(input: {
|
|
|
117
171
|
return { error: "You do not have permission to import media." };
|
|
118
172
|
}
|
|
119
173
|
|
|
174
|
+
return { id: user.id };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Download an external image (e.g. an AI-inserted stock photo) and persist it into the
|
|
179
|
+
* NextBlock media library (R2 or Supabase Storage) so it becomes a permanent, optimized
|
|
180
|
+
* asset the page no longer hotlinks. ADMIN/WRITER only.
|
|
181
|
+
*/
|
|
182
|
+
export async function importExternalImageToMedia(input: {
|
|
183
|
+
url: string;
|
|
184
|
+
altText?: string;
|
|
185
|
+
fileName?: string;
|
|
186
|
+
/**
|
|
187
|
+
* Uploader for callers with no cookie session — the MCP server authenticates by
|
|
188
|
+
* bearer token, so `auth.getUser()` finds nobody and every import would fail with
|
|
189
|
+
* "You must be signed in". The role check below still runs against this id, so it
|
|
190
|
+
* confers no authority the caller did not already establish.
|
|
191
|
+
*/
|
|
192
|
+
actorUserId?: string;
|
|
193
|
+
}): Promise<ImportExternalImageResult> {
|
|
194
|
+
const uploaderId = await resolveImportActorId(input.actorUserId);
|
|
195
|
+
|
|
196
|
+
if ("error" in uploaderId) {
|
|
197
|
+
return { error: uploaderId.error };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const userId = uploaderId.id;
|
|
201
|
+
|
|
120
202
|
let target: URL;
|
|
121
203
|
|
|
122
204
|
try {
|
|
@@ -233,7 +315,7 @@ export async function importExternalImageToMedia(input: {
|
|
|
233
315
|
Bucket: bucket,
|
|
234
316
|
ContentType: resolvedContentType,
|
|
235
317
|
Key: objectKey,
|
|
236
|
-
Metadata: { "uploader-user-id":
|
|
318
|
+
Metadata: { "uploader-user-id": userId },
|
|
237
319
|
})
|
|
238
320
|
);
|
|
239
321
|
}
|
|
@@ -258,6 +340,10 @@ export async function importExternalImageToMedia(input: {
|
|
|
258
340
|
|
|
259
341
|
const record = await recordMediaUpload(
|
|
260
342
|
{
|
|
343
|
+
// Carried through so the media row is attributed to the same actor the role
|
|
344
|
+
// check above passed — without it the recorder falls back to the cookie
|
|
345
|
+
// session and fails for MCP callers after the upload has already happened.
|
|
346
|
+
actorUserId: input.actorUserId,
|
|
261
347
|
blurDataUrl: blurDataUrl || undefined,
|
|
262
348
|
description: altText || undefined,
|
|
263
349
|
fileName,
|