@morphika/andami 0.2.26 → 0.3.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/app/admin/pages/[slug]/page.tsx +39 -45
- package/app/api/admin/assets/scan/route.ts +40 -13
- package/app/api/admin/custom-sections/[slug]/route.ts +4 -1
- package/app/api/admin/custom-sections/route.ts +4 -1
- package/app/api/admin/pages/[slug]/route.ts +7 -1
- package/app/api/admin/pages/route.ts +4 -1
- package/app/api/admin/r2/connect/route.ts +19 -1
- package/app/api/admin/r2/disconnect/route.ts +3 -0
- package/app/api/admin/r2/rename/route.ts +52 -13
- package/app/api/admin/r2/upload-url/route.ts +8 -1
- package/app/api/admin/settings/route.ts +4 -1
- package/app/api/admin/styles/route.ts +4 -1
- package/components/admin/styles/GridLayoutEditor.tsx +46 -46
- package/components/blocks/BlockRenderer.tsx +11 -2
- package/components/blocks/CoverSectionRenderer.tsx +75 -3
- package/components/blocks/ImageGridBlockRenderer.tsx +17 -11
- package/components/blocks/ParallaxGroupRenderer.tsx +45 -10
- package/components/blocks/ShaderCanvas.tsx +10 -6
- package/components/builder/BlockCardIcons.tsx +227 -0
- package/components/builder/BlockTypePicker.tsx +36 -63
- package/components/builder/BuilderCanvas.tsx +6 -2
- package/components/builder/ColumnDragOverlay.tsx +3 -3
- package/components/builder/CoverRowResizeHandle.tsx +5 -2
- package/components/builder/CoverSectionCanvas.tsx +45 -52
- package/components/builder/DndWrapper.tsx +1 -1
- package/components/builder/InsertionLines.tsx +1 -1
- package/components/builder/ParallaxGroupCanvas.tsx +12 -71
- package/components/builder/SectionCardIcons.tsx +266 -0
- package/components/builder/SectionEditorBar.tsx +17 -12
- package/components/builder/SectionTypePicker.tsx +33 -137
- package/components/builder/SectionV2Canvas.tsx +1 -1
- package/components/builder/SectionV2Column.tsx +19 -30
- package/components/builder/SettingsPanel.tsx +8 -32
- package/components/builder/SortableBlock.tsx +42 -50
- package/components/builder/SortableRow.tsx +207 -19
- package/components/builder/blockStyles.tsx +53 -180
- package/components/builder/iconPrimitives.tsx +78 -0
- package/components/builder/live-preview/LiveImagePreview.tsx +16 -2
- package/components/builder/live-preview/LiveVideoPreview.tsx +15 -2
- package/components/builder/settings-panel/ColumnV2Settings.tsx +5 -5
- package/components/builder/settings-panel/CoverSectionSettings.tsx +28 -1
- package/components/builder/settings-panel/SectionV2Settings.tsx +14 -14
- package/lib/assets.ts +17 -2
- package/lib/builder/constants.ts +22 -15
- package/lib/builder/format.ts +25 -0
- package/lib/builder/history.ts +0 -3
- package/lib/builder/layout-styles.ts +1 -1
- package/lib/builder/section-visibility.ts +36 -0
- package/lib/builder/serializer/normalizers.ts +15 -6
- package/lib/builder/serializer/serializers.ts +3 -3
- package/lib/builder/store-blocks.ts +16 -9
- package/lib/builder/store-cover.ts +76 -8
- package/lib/builder/store.ts +0 -2
- package/lib/builder/types.ts +1 -2
- package/lib/csrf.ts +31 -0
- package/lib/sanity/types.ts +4 -1
- package/lib/security.ts +50 -0
- package/lib/version.ts +1 -1
- package/package.json +1 -1
- package/sanity/schemas/objects/coverSection.ts +35 -3
- package/components/builder/ParallaxSlideHeader.tsx +0 -113
|
@@ -517,9 +517,12 @@ export default function PageEditorPage() {
|
|
|
517
517
|
{/* ---- Section Editor Bar (replaces toolbar in customSection mode) ---- */}
|
|
518
518
|
{isInSectionEditor && <SectionEditorBar />}
|
|
519
519
|
|
|
520
|
-
{/* ---- Toolbar (hidden in section editor mode) ----
|
|
521
|
-
|
|
522
|
-
|
|
520
|
+
{/* ---- Toolbar (hidden in section editor mode) ----
|
|
521
|
+
3-column grid so the center group ("Home" + publish chips) is
|
|
522
|
+
truly centered on the viewport regardless of left/right widths. */}
|
|
523
|
+
{!isInSectionEditor && <div className="grid grid-cols-[1fr_auto_1fr] items-center bg-white border-b border-neutral-200 px-4 h-14 shrink-0">
|
|
524
|
+
{/* LEFT — back link + history + help + page settings */}
|
|
525
|
+
<div className="flex items-center gap-3 justify-self-start">
|
|
523
526
|
<Link
|
|
524
527
|
href={store.pageType === "project" ? "/admin/projects" : "/admin/pages"}
|
|
525
528
|
className="text-xs text-neutral-400 hover:text-neutral-700 transition-colors flex items-center gap-1"
|
|
@@ -528,25 +531,8 @@ export default function PageEditorPage() {
|
|
|
528
531
|
{store.pageType === "project" ? "Projects" : "Pages"}
|
|
529
532
|
</Link>
|
|
530
533
|
<span className="text-neutral-200">|</span>
|
|
531
|
-
<span className="text-sm font-semibold text-neutral-800">{store.pageTitle}</span>
|
|
532
|
-
<span className="text-xs text-neutral-400 uppercase px-2 py-0.5 border border-neutral-200 rounded-lg bg-neutral-50">
|
|
533
|
-
{store.pageType}
|
|
534
|
-
</span>
|
|
535
|
-
<PublishToggle
|
|
536
|
-
mode="builder"
|
|
537
|
-
isDraft={store.draftMode}
|
|
538
|
-
onPublish={() => store.publishPage()}
|
|
539
|
-
onUnpublish={() => store.unpublishPage()}
|
|
540
|
-
/>
|
|
541
|
-
{store.isDirty && (
|
|
542
|
-
<span className="text-xs text-amber-500 animate-pulse">
|
|
543
|
-
Unsaved changes
|
|
544
|
-
</span>
|
|
545
|
-
)}
|
|
546
534
|
|
|
547
|
-
|
|
548
|
-
<div className="flex items-center gap-2">
|
|
549
|
-
{/* Undo/Redo buttons */}
|
|
535
|
+
{/* Undo / Redo */}
|
|
550
536
|
<button
|
|
551
537
|
onClick={() => store.undo()}
|
|
552
538
|
disabled={!store.canUndo()}
|
|
@@ -563,9 +549,8 @@ export default function PageEditorPage() {
|
|
|
563
549
|
>
|
|
564
550
|
↷
|
|
565
551
|
</button>
|
|
566
|
-
<span className="text-neutral-200 mx-1">|</span>
|
|
567
552
|
|
|
568
|
-
{/* Help
|
|
553
|
+
{/* Help */}
|
|
569
554
|
<button
|
|
570
555
|
onClick={() => setShowHelp(true)}
|
|
571
556
|
className="rounded-lg px-2 py-1 text-xs text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors"
|
|
@@ -574,7 +559,7 @@ export default function PageEditorPage() {
|
|
|
574
559
|
?
|
|
575
560
|
</button>
|
|
576
561
|
|
|
577
|
-
{/* Page settings gear
|
|
562
|
+
{/* Page settings gear */}
|
|
578
563
|
<button
|
|
579
564
|
onClick={() => store.clearSelection()}
|
|
580
565
|
className="rounded-lg px-2 py-1 text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors"
|
|
@@ -585,21 +570,29 @@ export default function PageEditorPage() {
|
|
|
585
570
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
|
586
571
|
</svg>
|
|
587
572
|
</button>
|
|
588
|
-
<span className="text-neutral-200
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
573
|
+
<span className="text-neutral-200">|</span>
|
|
574
|
+
</div>
|
|
575
|
+
|
|
576
|
+
{/* CENTER — page title + publish state */}
|
|
577
|
+
<div className="flex items-center gap-3 justify-self-center">
|
|
578
|
+
<span className="text-sm font-semibold text-neutral-800">{store.pageTitle}</span>
|
|
579
|
+
<PublishToggle
|
|
580
|
+
mode="builder"
|
|
581
|
+
isDraft={store.draftMode}
|
|
582
|
+
onPublish={() => store.publishPage()}
|
|
583
|
+
onUnpublish={() => store.unpublishPage()}
|
|
584
|
+
/>
|
|
585
|
+
</div>
|
|
586
|
+
|
|
587
|
+
{/* RIGHT — notification area + preview + save */}
|
|
588
|
+
<div className="flex items-center gap-3 justify-self-end">
|
|
589
|
+
{/* Notification slot: save error takes precedence over unsaved-changes */}
|
|
590
|
+
{store.saveError ? (
|
|
591
|
+
<span className="text-xs text-red-500">{store.saveError}</span>
|
|
592
|
+
) : store.isDirty ? (
|
|
593
|
+
<span className="text-xs text-amber-500 animate-pulse">Unsaved changes</span>
|
|
594
|
+
) : null}
|
|
595
|
+
|
|
603
596
|
{/* Preview in new tab */}
|
|
604
597
|
<button
|
|
605
598
|
onClick={() => openPreview(store)}
|
|
@@ -613,6 +606,7 @@ export default function PageEditorPage() {
|
|
|
613
606
|
</svg>
|
|
614
607
|
Preview
|
|
615
608
|
</button>
|
|
609
|
+
|
|
616
610
|
<button
|
|
617
611
|
onClick={handleSave}
|
|
618
612
|
disabled={store.isSaving || !store.isDirty}
|
|
@@ -813,18 +807,18 @@ export default function PageEditorPage() {
|
|
|
813
807
|
{/* Add section button — hidden in section editor mode */}
|
|
814
808
|
{!isInSectionEditor && (
|
|
815
809
|
<div className="relative" style={{ height: 0 }}>
|
|
816
|
-
<div className="absolute left-0 right-0 top-2" style={{ zIndex: 4 }}>
|
|
810
|
+
<div className="absolute left-0 right-0 top-2 flex justify-center" style={{ zIndex: 4 }}>
|
|
817
811
|
<button
|
|
818
812
|
onClick={(e) => {
|
|
819
813
|
e.stopPropagation();
|
|
820
814
|
setShowSectionPicker(true);
|
|
821
815
|
}}
|
|
822
|
-
className="
|
|
816
|
+
className="rounded-full text-[10px] font-medium transition-all hover:scale-105"
|
|
823
817
|
style={{
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
border: "1px
|
|
818
|
+
padding: "5px 16px",
|
|
819
|
+
background: "#e0daff",
|
|
820
|
+
color: "#7500d5",
|
|
821
|
+
border: "1px dashed #7500d5",
|
|
828
822
|
}}
|
|
829
823
|
>
|
|
830
824
|
+ Add Section
|
|
@@ -103,11 +103,13 @@ export async function POST(request: NextRequest) {
|
|
|
103
103
|
// Replace scannedFiles with filtered list (no _thumbs/ entries in registry)
|
|
104
104
|
scannedFiles = realFiles;
|
|
105
105
|
|
|
106
|
-
// Get existing assets from registry for merging
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
// Get existing assets from registry for merging.
|
|
107
|
+
// Capture `_rev` so the final patch can use optimistic concurrency (ifRevisionId).
|
|
108
|
+
const existingRegistry = await client.fetch<{ _rev?: string; assets?: RegisteredAsset[] } | null>(
|
|
109
|
+
`*[_type == "assetRegistry"][0]{ _rev, assets }`
|
|
109
110
|
);
|
|
110
111
|
const existingAssets: RegisteredAsset[] = existingRegistry?.assets || [];
|
|
112
|
+
const registryRev: string | undefined = existingRegistry?._rev;
|
|
111
113
|
|
|
112
114
|
// Build maps for fast lookup
|
|
113
115
|
const existingByPath = new Map<string, RegisteredAsset>();
|
|
@@ -228,16 +230,41 @@ export async function POST(request: NextRequest) {
|
|
|
228
230
|
}
|
|
229
231
|
}
|
|
230
232
|
|
|
231
|
-
// Update the registry
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
233
|
+
// Update the registry. Use optimistic concurrency (ifRevisionId) so a
|
|
234
|
+
// concurrent write to assetRegistry (e.g. an upload register hitting
|
|
235
|
+
// /api/admin/assets/register while we were merging) makes the commit
|
|
236
|
+
// fail loudly instead of silently overwriting the other change.
|
|
237
|
+
try {
|
|
238
|
+
const patch = writeClient.patch("assetRegistry");
|
|
239
|
+
if (registryRev) patch.ifRevisionId(registryRev);
|
|
240
|
+
await patch
|
|
241
|
+
.set({
|
|
242
|
+
assets: mergedAssets,
|
|
243
|
+
scan_status: "ready",
|
|
244
|
+
scan_error: "",
|
|
245
|
+
last_scanned_at: now,
|
|
246
|
+
})
|
|
247
|
+
.commit();
|
|
248
|
+
} catch (commitErr) {
|
|
249
|
+
const msg = commitErr instanceof Error ? commitErr.message : "";
|
|
250
|
+
// Sanity signals rev conflicts with a revision-mismatch error.
|
|
251
|
+
if (msg.toLowerCase().includes("revision")) {
|
|
252
|
+
// Clear the "scanning" marker so the UI isn't stuck
|
|
253
|
+
await writeClient
|
|
254
|
+
.patch("assetRegistry")
|
|
255
|
+
.set({ scan_status: "ready", scan_error: "Registry was modified during scan — please retry" })
|
|
256
|
+
.commit()
|
|
257
|
+
.catch(() => {});
|
|
258
|
+
return NextResponse.json(
|
|
259
|
+
{
|
|
260
|
+
error:
|
|
261
|
+
"Registry was modified by another process during this scan. Please retry.",
|
|
262
|
+
},
|
|
263
|
+
{ status: 409 }
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
throw commitErr;
|
|
267
|
+
}
|
|
241
268
|
|
|
242
269
|
return NextResponse.json({
|
|
243
270
|
success: true,
|
|
@@ -4,7 +4,7 @@ import { adminClient as client } from "../../../../../lib/sanity/client";
|
|
|
4
4
|
import { writeClient } from "../../../../../lib/sanity/writeClient";
|
|
5
5
|
import { customSectionBySlugQuery, pagesUsingCustomSectionQuery } from "../../../../../lib/sanity/queries";
|
|
6
6
|
import { isAdminAuthenticated } from "../../../../../lib/auth";
|
|
7
|
-
import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
|
|
7
|
+
import { validateCsrf, csrfErrorResponse, hasJsonContentType, contentTypeErrorResponse } from "../../../../../lib/csrf";
|
|
8
8
|
import { isBodyTooLarge, MAX_PAGE_BODY_SIZE } from "../../../../../lib/security";
|
|
9
9
|
import { auditLog } from "../../../../../lib/audit";
|
|
10
10
|
import { logger } from "../../../../../lib/logger";
|
|
@@ -46,6 +46,9 @@ export async function PATCH(request: NextRequest, context: RouteContext) {
|
|
|
46
46
|
if (!validateCsrf(request)) {
|
|
47
47
|
return csrfErrorResponse();
|
|
48
48
|
}
|
|
49
|
+
if (!hasJsonContentType(request)) {
|
|
50
|
+
return contentTypeErrorResponse();
|
|
51
|
+
}
|
|
49
52
|
if (isBodyTooLarge(request, MAX_PAGE_BODY_SIZE)) {
|
|
50
53
|
return NextResponse.json({ error: "Request body too large" }, { status: 413 });
|
|
51
54
|
}
|
|
@@ -3,7 +3,7 @@ import { adminClient as client } from "../../../../lib/sanity/client";
|
|
|
3
3
|
import { writeClient } from "../../../../lib/sanity/writeClient";
|
|
4
4
|
import { allCustomSectionsQuery } from "../../../../lib/sanity/queries";
|
|
5
5
|
import { isAdminAuthenticated } from "../../../../lib/auth";
|
|
6
|
-
import { validateCsrf, csrfErrorResponse } from "../../../../lib/csrf";
|
|
6
|
+
import { validateCsrf, csrfErrorResponse, hasJsonContentType, contentTypeErrorResponse } from "../../../../lib/csrf";
|
|
7
7
|
import { isBodyTooLarge, MAX_JSON_BODY_SIZE } from "../../../../lib/security";
|
|
8
8
|
import { auditLog } from "../../../../lib/audit";
|
|
9
9
|
import { logger } from "../../../../lib/logger";
|
|
@@ -39,6 +39,9 @@ export async function POST(request: NextRequest) {
|
|
|
39
39
|
if (!validateCsrf(request)) {
|
|
40
40
|
return csrfErrorResponse();
|
|
41
41
|
}
|
|
42
|
+
if (!hasJsonContentType(request)) {
|
|
43
|
+
return contentTypeErrorResponse();
|
|
44
|
+
}
|
|
42
45
|
if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
|
|
43
46
|
return NextResponse.json({ error: "Request body too large" }, { status: 413 });
|
|
44
47
|
}
|
|
@@ -5,7 +5,7 @@ import { writeClient } from "../../../../../lib/sanity/writeClient";
|
|
|
5
5
|
import { pageBySlugQuery } from "../../../../../lib/sanity/queries";
|
|
6
6
|
import { isAdminAuthenticated } from "../../../../../lib/auth";
|
|
7
7
|
import { isSafeUrl, isValidAssetPath, isBodyTooLarge, MAX_PAGE_BODY_SIZE, MAX_JSON_BODY_SIZE } from "../../../../../lib/security";
|
|
8
|
-
import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
|
|
8
|
+
import { validateCsrf, csrfErrorResponse, hasJsonContentType, contentTypeErrorResponse } from "../../../../../lib/csrf";
|
|
9
9
|
import { auditLog } from "../../../../../lib/audit";
|
|
10
10
|
import { logger } from "../../../../../lib/logger";
|
|
11
11
|
/** Raw nav_items from Sanity before GROQ projection resolves references */
|
|
@@ -250,6 +250,9 @@ export async function POST(
|
|
|
250
250
|
if (!validateCsrf(request)) {
|
|
251
251
|
return csrfErrorResponse();
|
|
252
252
|
}
|
|
253
|
+
if (!hasJsonContentType(request)) {
|
|
254
|
+
return contentTypeErrorResponse();
|
|
255
|
+
}
|
|
253
256
|
if (isBodyTooLarge(request, MAX_PAGE_BODY_SIZE)) {
|
|
254
257
|
return NextResponse.json({ error: "Request body too large" }, { status: 413 });
|
|
255
258
|
}
|
|
@@ -371,6 +374,9 @@ export async function PATCH(
|
|
|
371
374
|
if (!validateCsrf(request)) {
|
|
372
375
|
return csrfErrorResponse();
|
|
373
376
|
}
|
|
377
|
+
if (!hasJsonContentType(request)) {
|
|
378
|
+
return contentTypeErrorResponse();
|
|
379
|
+
}
|
|
374
380
|
|
|
375
381
|
if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
|
|
376
382
|
return NextResponse.json({ error: "Request body too large" }, { status: 413 });
|
|
@@ -3,7 +3,7 @@ import { adminClient as client } from "../../../../lib/sanity/client";
|
|
|
3
3
|
import { writeClient } from "../../../../lib/sanity/writeClient";
|
|
4
4
|
import { allPagesQuery } from "../../../../lib/sanity/queries";
|
|
5
5
|
import { isAdminAuthenticated } from "../../../../lib/auth";
|
|
6
|
-
import { validateCsrf, csrfErrorResponse } from "../../../../lib/csrf";
|
|
6
|
+
import { validateCsrf, csrfErrorResponse, hasJsonContentType, contentTypeErrorResponse } from "../../../../lib/csrf";
|
|
7
7
|
import { isBodyTooLarge, MAX_JSON_BODY_SIZE } from "../../../../lib/security";
|
|
8
8
|
import { auditLog } from "../../../../lib/audit";
|
|
9
9
|
import { logger } from "../../../../lib/logger";
|
|
@@ -39,6 +39,9 @@ export async function POST(request: NextRequest) {
|
|
|
39
39
|
if (!validateCsrf(request)) {
|
|
40
40
|
return csrfErrorResponse();
|
|
41
41
|
}
|
|
42
|
+
if (!hasJsonContentType(request)) {
|
|
43
|
+
return contentTypeErrorResponse();
|
|
44
|
+
}
|
|
42
45
|
if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
|
|
43
46
|
return NextResponse.json({ error: "Request body too large" }, { status: 413 });
|
|
44
47
|
}
|
|
@@ -5,6 +5,7 @@ import { writeClient } from "../../../../../lib/sanity/writeClient";
|
|
|
5
5
|
import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
|
|
6
6
|
import { encryptToken, isBodyTooLarge, MAX_JSON_BODY_SIZE, jsonError } from "../../../../../lib/security";
|
|
7
7
|
import { logger } from "../../../../../lib/logger";
|
|
8
|
+
import { invalidateProviderConfigCache } from "../../../../../lib/storage";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* POST /api/admin/r2/connect — Validate and store R2 credentials.
|
|
@@ -99,6 +100,9 @@ export async function POST(request: NextRequest) {
|
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
// #17: Test S3 API credentials (not just public URL) using HeadBucket
|
|
103
|
+
// Surface CORS-configuration outcome so the caller can warn the user.
|
|
104
|
+
let corsConfiguredResult = false;
|
|
105
|
+
let corsErrorResult: string | undefined;
|
|
102
106
|
try {
|
|
103
107
|
const testS3 = new S3Client({
|
|
104
108
|
region: "auto",
|
|
@@ -115,6 +119,8 @@ export async function POST(request: NextRequest) {
|
|
|
115
119
|
// from the Vercel deployment origin. We allow all origins with * because
|
|
116
120
|
// the presigned URL itself is the auth gate — only holders of a valid
|
|
117
121
|
// signed URL can upload. This also covers preview deploys and localhost.
|
|
122
|
+
let corsConfigured = true;
|
|
123
|
+
let corsErrorMessage: string | undefined;
|
|
118
124
|
try {
|
|
119
125
|
await testS3.send(
|
|
120
126
|
new PutBucketCorsCommand({
|
|
@@ -133,11 +139,17 @@ export async function POST(request: NextRequest) {
|
|
|
133
139
|
})
|
|
134
140
|
);
|
|
135
141
|
} catch (corsErr) {
|
|
142
|
+
corsConfigured = false;
|
|
143
|
+
corsErrorMessage = corsErr instanceof Error ? corsErr.message : "Unknown CORS error";
|
|
136
144
|
logger.warn("[Admin:R2]", "Failed to auto-configure CORS on R2 bucket", corsErr);
|
|
137
|
-
// Non-fatal —
|
|
145
|
+
// Non-fatal — surfaced in the response so the admin UI can prompt the
|
|
146
|
+
// user to configure CORS manually in the Cloudflare dashboard.
|
|
138
147
|
}
|
|
139
148
|
|
|
140
149
|
testS3.destroy();
|
|
150
|
+
// Hoist into outer scope so we can include it in the response below
|
|
151
|
+
corsConfiguredResult = corsConfigured;
|
|
152
|
+
corsErrorResult = corsErrorMessage;
|
|
141
153
|
} catch (s3Err) {
|
|
142
154
|
return jsonError(
|
|
143
155
|
`S3 credential validation failed: ${s3Err instanceof Error ? s3Err.message : "Could not authenticate with the provided credentials"}. Check your Access Key ID, Secret Access Key, and bucket name.`,
|
|
@@ -166,10 +178,16 @@ export async function POST(request: NextRequest) {
|
|
|
166
178
|
})
|
|
167
179
|
.commit();
|
|
168
180
|
|
|
181
|
+
invalidateProviderConfigCache();
|
|
182
|
+
|
|
169
183
|
return NextResponse.json({
|
|
170
184
|
success: true,
|
|
171
185
|
bucketName: bucketName.trim(),
|
|
172
186
|
publicUrl: publicUrl.trim().replace(/\/$/, ""),
|
|
187
|
+
corsConfigured: corsConfiguredResult,
|
|
188
|
+
corsWarning: corsConfiguredResult
|
|
189
|
+
? undefined
|
|
190
|
+
: `CORS auto-configuration failed${corsErrorResult ? ` (${corsErrorResult})` : ""}. Browser uploads may fail until you add a CORS rule in the Cloudflare dashboard allowing PUT from your domain.`,
|
|
173
191
|
});
|
|
174
192
|
} catch (err) {
|
|
175
193
|
logger.error("[Admin:R2]", "Failed to connect R2", err);
|
|
@@ -3,6 +3,7 @@ import { isAdminAuthenticated } from "../../../../../lib/auth";
|
|
|
3
3
|
import { writeClient } from "../../../../../lib/sanity/writeClient";
|
|
4
4
|
import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
|
|
5
5
|
import { logger } from "../../../../../lib/logger";
|
|
6
|
+
import { invalidateProviderConfigCache } from "../../../../../lib/storage";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* POST /api/admin/r2/disconnect — Clear R2 credentials from the registry.
|
|
@@ -31,6 +32,8 @@ export async function POST(request: NextRequest) {
|
|
|
31
32
|
])
|
|
32
33
|
.commit();
|
|
33
34
|
|
|
35
|
+
invalidateProviderConfigCache();
|
|
36
|
+
|
|
34
37
|
return NextResponse.json({ success: true });
|
|
35
38
|
} catch (err) {
|
|
36
39
|
logger.error("[Admin:R2]", "Failed to disconnect R2", err);
|
|
@@ -81,6 +81,7 @@ export async function POST(request: NextRequest) {
|
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
const renamedPairs: Array<{ oldPath: string; newPath: string }> = [];
|
|
84
|
+
const renameFailures: Array<{ key: string; reason: string }> = [];
|
|
84
85
|
|
|
85
86
|
if (isFolder) {
|
|
86
87
|
// Rename all objects under the old prefix
|
|
@@ -102,19 +103,48 @@ export async function POST(request: NextRequest) {
|
|
|
102
103
|
const objKey = obj.Key!;
|
|
103
104
|
const newObjKey = newPrefix + objKey.slice(oldPrefix.length);
|
|
104
105
|
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
106
|
+
// Per-object try/catch — one failed object shouldn't abort the whole
|
|
107
|
+
// rename and leave the folder half-moved. On copy failure we skip.
|
|
108
|
+
// On delete failure we attempt to roll back the copy so the user
|
|
109
|
+
// doesn't end up with duplicates.
|
|
110
|
+
try {
|
|
111
|
+
await s3.send(
|
|
112
|
+
new CopyObjectCommand({
|
|
113
|
+
Bucket: bucket,
|
|
114
|
+
CopySource: `${bucket}/${objKey}`,
|
|
115
|
+
Key: newObjKey,
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
} catch (copyErr) {
|
|
119
|
+
const reason = copyErr instanceof Error ? copyErr.message : "copy failed";
|
|
120
|
+
renameFailures.push({ key: objKey, reason });
|
|
121
|
+
logger.warn("[Admin:R2]", `rename: copy failed for ${objKey}`, copyErr);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
113
124
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
125
|
+
try {
|
|
126
|
+
await s3.send(
|
|
127
|
+
new DeleteObjectCommand({ Bucket: bucket, Key: objKey })
|
|
128
|
+
);
|
|
129
|
+
} catch (deleteErr) {
|
|
130
|
+
const reason = deleteErr instanceof Error ? deleteErr.message : "delete failed";
|
|
131
|
+
// Copy succeeded but delete failed — roll back the copy so the
|
|
132
|
+
// old key remains authoritative and the user doesn't see dupes.
|
|
133
|
+
try {
|
|
134
|
+
await s3.send(
|
|
135
|
+
new DeleteObjectCommand({ Bucket: bucket, Key: newObjKey })
|
|
136
|
+
);
|
|
137
|
+
} catch (rollbackErr) {
|
|
138
|
+
logger.warn(
|
|
139
|
+
"[Admin:R2]",
|
|
140
|
+
`rename: rollback delete failed for ${newObjKey} — manual cleanup needed`,
|
|
141
|
+
rollbackErr
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
renameFailures.push({ key: objKey, reason: `delete failed (${reason})` });
|
|
145
|
+
logger.warn("[Admin:R2]", `rename: delete failed for ${objKey}`, deleteErr);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
118
148
|
|
|
119
149
|
renamedPairs.push({ oldPath: objKey, newPath: newObjKey });
|
|
120
150
|
}
|
|
@@ -247,11 +277,20 @@ export async function POST(request: NextRequest) {
|
|
|
247
277
|
}
|
|
248
278
|
}
|
|
249
279
|
|
|
250
|
-
auditLog("r2.rename", {
|
|
280
|
+
auditLog("r2.rename", {
|
|
281
|
+
oldKey,
|
|
282
|
+
newKey,
|
|
283
|
+
isFolder,
|
|
284
|
+
count: renamedPairs.length,
|
|
285
|
+
failed: renameFailures.length,
|
|
286
|
+
});
|
|
251
287
|
|
|
252
288
|
return NextResponse.json({
|
|
253
289
|
success: true,
|
|
254
290
|
renamedCount: renamedPairs.length,
|
|
291
|
+
failedCount: renameFailures.length,
|
|
292
|
+
failures: renameFailures,
|
|
293
|
+
partial: renameFailures.length > 0,
|
|
255
294
|
});
|
|
256
295
|
} catch (err) {
|
|
257
296
|
// #5: Gracefully handle corrupted encrypted tokens
|
|
@@ -3,7 +3,7 @@ import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
|
|
3
3
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
4
4
|
import { isAdminAuthenticated } from "../../../../../lib/auth";
|
|
5
5
|
import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
|
|
6
|
-
import { isBodyTooLarge, MAX_JSON_BODY_SIZE, jsonError, isValidAssetPath, checkRateLimit } from "../../../../../lib/security";
|
|
6
|
+
import { isBodyTooLarge, MAX_JSON_BODY_SIZE, jsonError, isValidAssetPath, isValidUploadKey, checkRateLimit } from "../../../../../lib/security";
|
|
7
7
|
import { adminClient as client } from "../../../../../lib/sanity/client";
|
|
8
8
|
import { decryptToken } from "../../../../../lib/security";
|
|
9
9
|
import { getMimeType, isMediaFile } from "../../../../../lib/storage/types";
|
|
@@ -77,6 +77,13 @@ export async function POST(request: NextRequest) {
|
|
|
77
77
|
// ── Build the R2 object key ──
|
|
78
78
|
const key = cleanFolder ? `${cleanFolder}/${filename}` : filename;
|
|
79
79
|
|
|
80
|
+
// Defense-in-depth: presigned keys must pass the upload-key allowlist.
|
|
81
|
+
// Rejects reserved prefixes (backup/, system/), hidden files, `_`-prefixed
|
|
82
|
+
// folders other than `_thumbs/`, pathological depth, etc.
|
|
83
|
+
if (!isValidUploadKey(key)) {
|
|
84
|
+
return jsonError("Upload path is not allowed", 400);
|
|
85
|
+
}
|
|
86
|
+
|
|
80
87
|
// ── Resolve content type ──
|
|
81
88
|
const resolvedContentType =
|
|
82
89
|
contentType && typeof contentType === "string"
|
|
@@ -4,7 +4,7 @@ import { adminClient as client } from "../../../../lib/sanity/client";
|
|
|
4
4
|
import { writeClient } from "../../../../lib/sanity/writeClient";
|
|
5
5
|
import { siteSettingsQuery } from "../../../../lib/sanity/queries";
|
|
6
6
|
import { isSafeUrl, isBodyTooLarge, MAX_JSON_BODY_SIZE } from "../../../../lib/security";
|
|
7
|
-
import { validateCsrf, csrfErrorResponse } from "../../../../lib/csrf";
|
|
7
|
+
import { validateCsrf, csrfErrorResponse, hasJsonContentType, contentTypeErrorResponse } from "../../../../lib/csrf";
|
|
8
8
|
import { auditLog } from "../../../../lib/audit";
|
|
9
9
|
import { getSiteConfig } from "../../../../lib/config";
|
|
10
10
|
import { logger } from "../../../../lib/logger";
|
|
@@ -71,6 +71,9 @@ export async function POST(request: NextRequest) {
|
|
|
71
71
|
if (!validateCsrf(request)) {
|
|
72
72
|
return csrfErrorResponse();
|
|
73
73
|
}
|
|
74
|
+
if (!hasJsonContentType(request)) {
|
|
75
|
+
return contentTypeErrorResponse();
|
|
76
|
+
}
|
|
74
77
|
if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
|
|
75
78
|
return NextResponse.json({ error: "Request body too large" }, { status: 413 });
|
|
76
79
|
}
|
|
@@ -3,7 +3,7 @@ import { isAdminAuthenticated } from "../../../../lib/auth";
|
|
|
3
3
|
import { adminClient as client } from "../../../../lib/sanity/client";
|
|
4
4
|
import { writeClient } from "../../../../lib/sanity/writeClient";
|
|
5
5
|
import { siteStylesQuery } from "../../../../lib/sanity/queries";
|
|
6
|
-
import { validateCsrf, csrfErrorResponse } from "../../../../lib/csrf";
|
|
6
|
+
import { validateCsrf, csrfErrorResponse, hasJsonContentType, contentTypeErrorResponse } from "../../../../lib/csrf";
|
|
7
7
|
import { isBodyTooLarge, MAX_JSON_BODY_SIZE } from "../../../../lib/security";
|
|
8
8
|
import { logger } from "../../../../lib/logger";
|
|
9
9
|
import { DEFAULT_GRID_WIDTH } from "../../../../lib/builder/constants";
|
|
@@ -158,6 +158,9 @@ export async function POST(request: NextRequest) {
|
|
|
158
158
|
if (!validateCsrf(request)) {
|
|
159
159
|
return csrfErrorResponse();
|
|
160
160
|
}
|
|
161
|
+
if (!hasJsonContentType(request)) {
|
|
162
|
+
return contentTypeErrorResponse();
|
|
163
|
+
}
|
|
161
164
|
if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
|
|
162
165
|
return NextResponse.json({ error: "Request body too large" }, { status: 413 });
|
|
163
166
|
}
|