create-nextblock 0.14.6 → 0.15.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/cron/reset-sandbox/sandboxResetSql.ts +190 -1
- package/templates/nextblock-template/app/api/mcp/route.ts +82 -13
- 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/site-scripts/actions.ts +346 -0
- package/templates/nextblock-template/app/cms/settings/site-scripts/components/SiteScriptManager.tsx +500 -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/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +136 -0
- package/templates/nextblock-template/lib/blocks/inlineScriptNonce.ts +20 -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/next-env.d.ts +2 -2
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -6607,6 +6607,193 @@ DROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;
|
|
|
6607
6607
|
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])) 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])) AND (( SELECT public.get_current_user_role() AS get_current_user_role) = 'ADMIN'::public.user_role))));
|
|
6608
6608
|
|
|
6609
6609
|
|
|
6610
|
+
-- >>> FROM: 00000000000018_site_scripts.sql <<<
|
|
6611
|
+
-- Site scripts: admin-authored JavaScript injected into every page of the public site.
|
|
6612
|
+
--
|
|
6613
|
+
-- Rich-text blocks can already carry an inline <script>, but that script belongs to
|
|
6614
|
+
-- one block on one page. This table is for behaviour that spans the site: chat
|
|
6615
|
+
-- widgets, third-party embeds, and the scroll/animation helpers that page classes
|
|
6616
|
+
-- rely on. Each row gets a name, an on/off switch, and a defined injection point.
|
|
6617
|
+
--
|
|
6618
|
+
-- NOT the same thing as \`site_settings.privacy_settings -> custom_scripts\`, which is
|
|
6619
|
+
-- a single consent-gated blob for marketing tags and only fires once a visitor
|
|
6620
|
+
-- accepts cookies. Rows here are functional site code and run unconditionally, so
|
|
6621
|
+
-- anything requiring consent belongs in that setting instead, not this table.
|
|
6622
|
+
--
|
|
6623
|
+
-- Scripts are emitted with the request's CSP nonce by the root layout, so they run
|
|
6624
|
+
-- under the site's existing Content-Security-Policy rather than forcing it open.
|
|
6625
|
+
--
|
|
6626
|
+
-- Security posture: this is arbitrary JavaScript on every page, so writes are
|
|
6627
|
+
-- ADMIN-only (WRITER is deliberately excluded, unlike most content tables) and the
|
|
6628
|
+
-- public may read only rows that are switched on, so a half-written draft is never
|
|
6629
|
+
-- served to a visitor.
|
|
6630
|
+
|
|
6631
|
+
CREATE TABLE IF NOT EXISTS public.site_scripts (
|
|
6632
|
+
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
|
6633
|
+
name text NOT NULL,
|
|
6634
|
+
description text,
|
|
6635
|
+
-- Raw JavaScript, stored WITHOUT the surrounding <script> tag. The layout adds
|
|
6636
|
+
-- the tag so the nonce and attributes are always applied by us, never by the
|
|
6637
|
+
-- author. Ignored when \`src\` is set.
|
|
6638
|
+
code text DEFAULT ''::text NOT NULL,
|
|
6639
|
+
-- When set, an external script is loaded from this URL and \`code\` is ignored.
|
|
6640
|
+
src text,
|
|
6641
|
+
-- Where the tag is emitted. 'head' runs before first paint (blocking, use
|
|
6642
|
+
-- sparingly); 'body_end' runs once the markup exists and is the right default
|
|
6643
|
+
-- for anything that queries the DOM.
|
|
6644
|
+
placement text DEFAULT 'body_end'::text NOT NULL,
|
|
6645
|
+
-- Applies to external \`src\` scripts; inline code ignores it.
|
|
6646
|
+
load_strategy text DEFAULT 'default'::text NOT NULL,
|
|
6647
|
+
is_active boolean DEFAULT false NOT NULL,
|
|
6648
|
+
sort_order integer DEFAULT 0 NOT NULL,
|
|
6649
|
+
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
6650
|
+
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
6651
|
+
CONSTRAINT site_scripts_pkey PRIMARY KEY (id),
|
|
6652
|
+
CONSTRAINT site_scripts_placement_check
|
|
6653
|
+
CHECK ((placement = ANY (ARRAY['head'::text, 'body_start'::text, 'body_end'::text]))),
|
|
6654
|
+
CONSTRAINT site_scripts_load_strategy_check
|
|
6655
|
+
CHECK ((load_strategy = ANY (ARRAY['default'::text, 'defer'::text, 'async'::text]))),
|
|
6656
|
+
-- An external script must be https so it cannot be downgraded in transit.
|
|
6657
|
+
CONSTRAINT site_scripts_src_scheme_check
|
|
6658
|
+
CHECK ((src IS NULL OR src ~ '^https://')),
|
|
6659
|
+
-- A row has to actually do something: inline code or an external src.
|
|
6660
|
+
CONSTRAINT site_scripts_has_payload_check
|
|
6661
|
+
CHECK ((src IS NOT NULL OR length(btrim(code)) > 0))
|
|
6662
|
+
);
|
|
6663
|
+
|
|
6664
|
+
COMMENT ON TABLE public.site_scripts IS
|
|
6665
|
+
'Admin-authored JavaScript injected into the public site by the root layout, with the request CSP nonce applied. Only is_active rows are publicly readable; only ADMIN may write. Distinct from privacy_settings.custom_scripts, which is consent-gated marketing tags.';
|
|
6666
|
+
|
|
6667
|
+
CREATE INDEX IF NOT EXISTS site_scripts_active_placement_sort_idx
|
|
6668
|
+
ON public.site_scripts USING btree (is_active, placement, sort_order);
|
|
6669
|
+
|
|
6670
|
+
DROP TRIGGER IF EXISTS set_site_scripts_updated_at ON public.site_scripts;
|
|
6671
|
+
CREATE TRIGGER set_site_scripts_updated_at
|
|
6672
|
+
BEFORE UPDATE ON public.site_scripts
|
|
6673
|
+
FOR EACH ROW EXECUTE FUNCTION public.set_current_timestamp_updated_at();
|
|
6674
|
+
|
|
6675
|
+
ALTER TABLE public.site_scripts ENABLE ROW LEVEL SECURITY;
|
|
6676
|
+
|
|
6677
|
+
GRANT ALL ON TABLE public.site_scripts TO anon;
|
|
6678
|
+
GRANT ALL ON TABLE public.site_scripts TO authenticated;
|
|
6679
|
+
GRANT ALL ON TABLE public.site_scripts TO service_role;
|
|
6680
|
+
|
|
6681
|
+
-- Anonymous visitors need the active scripts to render the page. Inactive rows stay
|
|
6682
|
+
-- private so a half-written script is never exposed before it is switched on.
|
|
6683
|
+
DROP POLICY IF EXISTS "Public read active site scripts" ON public.site_scripts;
|
|
6684
|
+
CREATE POLICY "Public read active site scripts" ON public.site_scripts
|
|
6685
|
+
FOR SELECT TO authenticated, anon USING (is_active);
|
|
6686
|
+
|
|
6687
|
+
DROP POLICY IF EXISTS "Admins read all site scripts" ON public.site_scripts;
|
|
6688
|
+
CREATE POLICY "Admins read all site scripts" ON public.site_scripts
|
|
6689
|
+
FOR SELECT TO authenticated
|
|
6690
|
+
USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
|
|
6691
|
+
|
|
6692
|
+
DROP POLICY IF EXISTS "Admins insert site scripts" ON public.site_scripts;
|
|
6693
|
+
CREATE POLICY "Admins insert site scripts" ON public.site_scripts
|
|
6694
|
+
FOR INSERT TO authenticated
|
|
6695
|
+
WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
|
|
6696
|
+
|
|
6697
|
+
DROP POLICY IF EXISTS "Admins update site scripts" ON public.site_scripts;
|
|
6698
|
+
CREATE POLICY "Admins update site scripts" ON public.site_scripts
|
|
6699
|
+
FOR UPDATE TO authenticated
|
|
6700
|
+
USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role))
|
|
6701
|
+
WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
|
|
6702
|
+
|
|
6703
|
+
DROP POLICY IF EXISTS "Admins delete site scripts" ON public.site_scripts;
|
|
6704
|
+
CREATE POLICY "Admins delete site scripts" ON public.site_scripts
|
|
6705
|
+
FOR DELETE TO authenticated
|
|
6706
|
+
USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
|
|
6707
|
+
|
|
6708
|
+
|
|
6709
|
+
-- >>> FROM: 00000000000019_site_script_revisions.sql <<<
|
|
6710
|
+
-- Audit trail and undo for site scripts.
|
|
6711
|
+
--
|
|
6712
|
+
-- \`site_scripts\` ships arbitrary JavaScript to every visitor, which makes it the
|
|
6713
|
+
-- highest-privilege write in the CMS: a bad or malicious snippet can read cookies,
|
|
6714
|
+
-- watch checkout forms, or phone home. Content has Revision History for exactly this
|
|
6715
|
+
-- reason; code needs it more, not less. Every create/update/delete writes one row
|
|
6716
|
+
-- here, and every row is a complete, restorable snapshot — so this table is both the
|
|
6717
|
+
-- log ("who shipped what, when, from where") and the undo.
|
|
6718
|
+
--
|
|
6719
|
+
-- APPEND-ONLY BY CONSTRUCTION. There are no UPDATE or DELETE policies, and the
|
|
6720
|
+
-- trigger below rejects both even for the service role, which otherwise bypasses
|
|
6721
|
+
-- RLS. An audit trail that the compromised credential can rewrite is not an audit
|
|
6722
|
+
-- trail. Reverting therefore writes a NEW 'revert' row rather than removing history.
|
|
6723
|
+
--
|
|
6724
|
+
-- \`script_id\` and \`actor_user_id\` are deliberately PLAIN uuids with no foreign keys:
|
|
6725
|
+
-- an FK with ON DELETE SET NULL would have to UPDATE this table when a script or a
|
|
6726
|
+
-- profile is deleted, which the append-only trigger forbids. \`script_name\` is
|
|
6727
|
+
-- denormalised so a deleted script is still identifiable in the log.
|
|
6728
|
+
|
|
6729
|
+
CREATE TABLE IF NOT EXISTS public.site_script_revisions (
|
|
6730
|
+
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
|
6731
|
+
script_id uuid,
|
|
6732
|
+
script_name text NOT NULL,
|
|
6733
|
+
revision_type text NOT NULL,
|
|
6734
|
+
-- Null when the actor could not be resolved (e.g. a localhost dev connection).
|
|
6735
|
+
actor_user_id uuid,
|
|
6736
|
+
-- Which surface made the change, so an unexpected edit can be traced back to
|
|
6737
|
+
-- the dashboard or to an MCP token.
|
|
6738
|
+
source text DEFAULT 'cms'::text NOT NULL,
|
|
6739
|
+
summary text,
|
|
6740
|
+
-- Full restorable state of the script at this revision. For 'delete' it is the
|
|
6741
|
+
-- state immediately BEFORE removal, so restoring it brings the script back.
|
|
6742
|
+
snapshot jsonb NOT NULL,
|
|
6743
|
+
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
|
6744
|
+
CONSTRAINT site_script_revisions_pkey PRIMARY KEY (id),
|
|
6745
|
+
CONSTRAINT site_script_revisions_type_check
|
|
6746
|
+
CHECK ((revision_type = ANY (ARRAY['create'::text, 'update'::text, 'delete'::text, 'revert'::text]))),
|
|
6747
|
+
CONSTRAINT site_script_revisions_source_check
|
|
6748
|
+
CHECK ((source = ANY (ARRAY['cms'::text, 'mcp'::text]))),
|
|
6749
|
+
CONSTRAINT site_script_revisions_snapshot_is_object_check
|
|
6750
|
+
CHECK ((jsonb_typeof(snapshot) = 'object'))
|
|
6751
|
+
);
|
|
6752
|
+
|
|
6753
|
+
COMMENT ON TABLE public.site_script_revisions IS
|
|
6754
|
+
'Append-only audit log and undo history for site_scripts. Each row is a restorable snapshot. UPDATE and DELETE are blocked by trigger, including for the service role.';
|
|
6755
|
+
|
|
6756
|
+
CREATE INDEX IF NOT EXISTS site_script_revisions_script_created_idx
|
|
6757
|
+
ON public.site_script_revisions USING btree (script_id, created_at DESC);
|
|
6758
|
+
|
|
6759
|
+
CREATE INDEX IF NOT EXISTS site_script_revisions_created_idx
|
|
6760
|
+
ON public.site_script_revisions USING btree (created_at DESC);
|
|
6761
|
+
|
|
6762
|
+
-- Enforced in the database rather than the application so it holds for every
|
|
6763
|
+
-- caller, including the service-role client the MCP server uses.
|
|
6764
|
+
CREATE OR REPLACE FUNCTION public.prevent_site_script_revision_rewrite() RETURNS trigger
|
|
6765
|
+
LANGUAGE plpgsql
|
|
6766
|
+
SET search_path = ''
|
|
6767
|
+
AS $$
|
|
6768
|
+
BEGIN
|
|
6769
|
+
RAISE EXCEPTION 'site_script_revisions is append-only; % is not permitted', TG_OP
|
|
6770
|
+
USING ERRCODE = 'restrict_violation';
|
|
6771
|
+
END;
|
|
6772
|
+
$$;
|
|
6773
|
+
|
|
6774
|
+
DROP TRIGGER IF EXISTS trg_site_script_revisions_append_only ON public.site_script_revisions;
|
|
6775
|
+
CREATE TRIGGER trg_site_script_revisions_append_only
|
|
6776
|
+
BEFORE UPDATE OR DELETE ON public.site_script_revisions
|
|
6777
|
+
FOR EACH ROW EXECUTE FUNCTION public.prevent_site_script_revision_rewrite();
|
|
6778
|
+
|
|
6779
|
+
ALTER TABLE public.site_script_revisions ENABLE ROW LEVEL SECURITY;
|
|
6780
|
+
|
|
6781
|
+
GRANT SELECT, INSERT ON TABLE public.site_script_revisions TO authenticated;
|
|
6782
|
+
GRANT ALL ON TABLE public.site_script_revisions TO service_role;
|
|
6783
|
+
|
|
6784
|
+
-- Read is ADMIN-only: snapshots contain the full source of scripts that may not be
|
|
6785
|
+
-- active yet, and the log itself reveals operational history.
|
|
6786
|
+
DROP POLICY IF EXISTS "Admins read site script revisions" ON public.site_script_revisions;
|
|
6787
|
+
CREATE POLICY "Admins read site script revisions" ON public.site_script_revisions
|
|
6788
|
+
FOR SELECT TO authenticated
|
|
6789
|
+
USING (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
|
|
6790
|
+
|
|
6791
|
+
DROP POLICY IF EXISTS "Admins insert site script revisions" ON public.site_script_revisions;
|
|
6792
|
+
CREATE POLICY "Admins insert site script revisions" ON public.site_script_revisions
|
|
6793
|
+
FOR INSERT TO authenticated
|
|
6794
|
+
WITH CHECK (((SELECT public.get_current_user_role()) = 'ADMIN'::public.user_role));
|
|
6795
|
+
|
|
6796
|
+
|
|
6610
6797
|
-- Step D: Record the applied migrations in history (truncated in Step B) so
|
|
6611
6798
|
-- \`npm run db:migrate:check\` reports up to date instead of listing every file as pending.
|
|
6612
6799
|
INSERT INTO supabase_migrations.schema_migrations (version, name) VALUES
|
|
@@ -6627,7 +6814,9 @@ CREATE POLICY site_settings_delete_policy ON public.site_settings FOR DELETE TO
|
|
|
6627
6814
|
('00000000000014', 'site_themes'),
|
|
6628
6815
|
('00000000000015', 'scheduled_publishing'),
|
|
6629
6816
|
('00000000000016', 'product_revisions_and_revision_baseline'),
|
|
6630
|
-
('00000000000017', 'cortex_ai_mcp_server')
|
|
6817
|
+
('00000000000017', 'cortex_ai_mcp_server'),
|
|
6818
|
+
('00000000000018', 'site_scripts'),
|
|
6819
|
+
('00000000000019', 'site_script_revisions')
|
|
6631
6820
|
ON CONFLICT (version) DO NOTHING;
|
|
6632
6821
|
|
|
6633
6822
|
-- Step E: Anchor preserved profiles
|
|
@@ -55,21 +55,45 @@ const MCP_SKIP_CONFIRMATION = true;
|
|
|
55
55
|
|
|
56
56
|
type McpAuth = {
|
|
57
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;
|
|
58
70
|
scopes: CortexAiMcpScope[];
|
|
59
71
|
source: 'admin-session' | 'localhost' | 'token';
|
|
60
72
|
};
|
|
61
73
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
+
});
|
|
67
90
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
91
|
+
if ('error' in result) {
|
|
92
|
+
return { error: result.error };
|
|
93
|
+
}
|
|
71
94
|
|
|
72
|
-
|
|
95
|
+
return { id: result.media.id };
|
|
96
|
+
};
|
|
73
97
|
}
|
|
74
98
|
|
|
75
99
|
/** Mirrors the global-agent route so MCP writes land in Revision History like any other edit. */
|
|
@@ -173,7 +197,8 @@ async function authenticateMcpRequest(request: Request): Promise<McpAuth | null>
|
|
|
173
197
|
void touchCortexAiMcpToken(serviceClient, verification.token.id);
|
|
174
198
|
|
|
175
199
|
return {
|
|
176
|
-
|
|
200
|
+
actorFromOrphanedToken: !verification.token.created_by,
|
|
201
|
+
actorUserId: verification.token.created_by ?? (await resolveFallbackAdminUserId()),
|
|
177
202
|
scopes: verification.scopes,
|
|
178
203
|
source: 'token',
|
|
179
204
|
};
|
|
@@ -182,16 +207,59 @@ async function authenticateMcpRequest(request: Request): Promise<McpAuth | null>
|
|
|
182
207
|
const adminUserId = await resolveAdminSessionUserId();
|
|
183
208
|
|
|
184
209
|
if (adminUserId) {
|
|
185
|
-
return {
|
|
210
|
+
return {
|
|
211
|
+
actorFromOrphanedToken: false,
|
|
212
|
+
actorUserId: adminUserId,
|
|
213
|
+
scopes: ['read', 'write'],
|
|
214
|
+
source: 'admin-session',
|
|
215
|
+
};
|
|
186
216
|
}
|
|
187
217
|
|
|
188
218
|
if (shouldTrustLocalMcpRequest({ hostHeader: request.headers.get('host'), settings })) {
|
|
189
|
-
|
|
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
|
+
};
|
|
190
228
|
}
|
|
191
229
|
|
|
192
230
|
return null;
|
|
193
231
|
}
|
|
194
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
|
+
|
|
195
263
|
async function resolveAdminSessionUserId(): Promise<string | null> {
|
|
196
264
|
try {
|
|
197
265
|
const supabase = createClient();
|
|
@@ -217,8 +285,9 @@ async function resolveAdminSessionUserId(): Promise<string | null> {
|
|
|
217
285
|
|
|
218
286
|
function buildToolContext(auth: McpAuth): CortexMcpToolContext {
|
|
219
287
|
return {
|
|
288
|
+
actorFromOrphanedToken: auth.actorFromOrphanedToken,
|
|
220
289
|
actorUserId: auth.actorUserId,
|
|
221
|
-
importExternalImage:
|
|
290
|
+
importExternalImage: createMcpImageImporter(auth.actorUserId),
|
|
222
291
|
// No open editor over MCP: tools that need a target take it in their arguments
|
|
223
292
|
// (`cmsTarget`, `slug`, `entityId`) rather than inheriting one from a UI.
|
|
224
293
|
pageContext: null,
|
|
@@ -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,
|