@morphika/andami 0.2.26 → 0.4.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.
Files changed (81) hide show
  1. package/app/admin/pages/[slug]/page.tsx +41 -47
  2. package/app/api/admin/assets/scan/route.ts +40 -13
  3. package/app/api/admin/custom-sections/[slug]/route.ts +4 -1
  4. package/app/api/admin/custom-sections/route.ts +4 -1
  5. package/app/api/admin/pages/[slug]/route.ts +7 -1
  6. package/app/api/admin/pages/route.ts +4 -1
  7. package/app/api/admin/r2/connect/route.ts +19 -1
  8. package/app/api/admin/r2/disconnect/route.ts +3 -0
  9. package/app/api/admin/r2/rename/route.ts +52 -13
  10. package/app/api/admin/r2/upload-url/route.ts +8 -1
  11. package/app/api/admin/settings/route.ts +4 -1
  12. package/app/api/admin/styles/route.ts +4 -1
  13. package/components/admin/styles/GridLayoutEditor.tsx +46 -46
  14. package/components/blocks/BlockRenderer.tsx +15 -2
  15. package/components/blocks/CoverSectionRenderer.tsx +75 -3
  16. package/components/blocks/ImageGridBlockRenderer.tsx +17 -11
  17. package/components/blocks/ParallaxGroupRenderer.tsx +45 -10
  18. package/components/blocks/ProjectCarouselBlockRenderer.tsx +527 -0
  19. package/components/blocks/ShaderCanvas.tsx +10 -6
  20. package/components/builder/BlockCardIcons.tsx +227 -0
  21. package/components/builder/BlockLivePreview.tsx +5 -0
  22. package/components/builder/BlockTypePicker.tsx +36 -63
  23. package/components/builder/BuilderCanvas.tsx +6 -2
  24. package/components/builder/ColumnDragOverlay.tsx +3 -3
  25. package/components/builder/CoverRowResizeHandle.tsx +5 -2
  26. package/components/builder/CoverSectionCanvas.tsx +45 -52
  27. package/components/builder/DndWrapper.tsx +1 -1
  28. package/components/builder/InsertionLines.tsx +1 -1
  29. package/components/builder/ParallaxGroupCanvas.tsx +12 -71
  30. package/components/builder/ReadOnlyFrame.tsx +4 -23
  31. package/components/builder/SectionCardIcons.tsx +320 -0
  32. package/components/builder/SectionEditorBar.tsx +17 -12
  33. package/components/builder/SectionTypePicker.tsx +34 -138
  34. package/components/builder/SectionV2Canvas.tsx +1 -1
  35. package/components/builder/SectionV2Column.tsx +19 -30
  36. package/components/builder/SettingsPanel.tsx +8 -32
  37. package/components/builder/SortableBlock.tsx +42 -50
  38. package/components/builder/SortableRow.tsx +207 -19
  39. package/components/builder/blockStyles.tsx +59 -180
  40. package/components/builder/editors/ProjectCarouselBlockEditor.tsx +443 -0
  41. package/components/builder/editors/index.ts +1 -0
  42. package/components/builder/iconPrimitives.tsx +78 -0
  43. package/components/builder/live-preview/LiveImagePreview.tsx +16 -2
  44. package/components/builder/live-preview/LiveProjectCarouselPreview.tsx +227 -0
  45. package/components/builder/live-preview/LiveVideoPreview.tsx +15 -2
  46. package/components/builder/live-preview/index.ts +1 -0
  47. package/components/builder/settings-panel/BlockSettings.tsx +7 -0
  48. package/components/builder/settings-panel/ColumnV2Settings.tsx +5 -5
  49. package/components/builder/settings-panel/CoverSectionSettings.tsx +28 -1
  50. package/components/builder/settings-panel/SectionV2Settings.tsx +14 -14
  51. package/lib/animation/enter-types.ts +1 -0
  52. package/lib/animation/hover-effect-types.ts +1 -0
  53. package/lib/assets.ts +17 -2
  54. package/lib/builder/block-registrations.ts +268 -0
  55. package/lib/builder/block-registry.ts +195 -0
  56. package/lib/builder/constants.ts +22 -15
  57. package/lib/builder/defaults.ts +21 -0
  58. package/lib/builder/format.ts +25 -0
  59. package/lib/builder/history.ts +0 -3
  60. package/lib/builder/index.ts +16 -0
  61. package/lib/builder/layout-styles.ts +1 -1
  62. package/lib/builder/registry.ts +44 -0
  63. package/lib/builder/section-visibility.ts +36 -0
  64. package/lib/builder/serializer/normalizers.ts +15 -6
  65. package/lib/builder/serializer/serializers.ts +3 -3
  66. package/lib/builder/store-blocks.ts +16 -9
  67. package/lib/builder/store-cover.ts +76 -8
  68. package/lib/builder/store-sections.ts +1 -1
  69. package/lib/builder/store.ts +0 -2
  70. package/lib/builder/types.ts +9 -5
  71. package/lib/csrf.ts +31 -0
  72. package/lib/sanity/types.ts +54 -2
  73. package/lib/security.ts +50 -0
  74. package/lib/version.ts +1 -1
  75. package/package.json +1 -1
  76. package/sanity/schemas/blocks/index.ts +2 -1
  77. package/sanity/schemas/blocks/projectCarouselBlock.ts +218 -0
  78. package/sanity/schemas/index.ts +4 -1
  79. package/sanity/schemas/objects/coverSection.ts +35 -3
  80. package/sanity/schemas/pageSectionV2.ts +1 -0
  81. package/components/builder/ParallaxSlideHeader.tsx +0 -113
@@ -415,10 +415,10 @@ export default function PageEditorPage() {
415
415
  [store]
416
416
  );
417
417
 
418
- // Handle add page section (project grid)
418
+ // Handle add page section (project grid / project carousel)
419
419
  // afterRowKey=null → always appends to end of page (bottom "Add Section" button)
420
420
  const handleAddSection = useCallback(
421
- (blockType: "projectGridBlock") => {
421
+ (blockType: "projectGridBlock" | "projectCarouselBlock") => {
422
422
  store.addSection(blockType, null);
423
423
  setShowSectionPicker(false);
424
424
  },
@@ -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
- {!isInSectionEditor && <div className="flex items-center justify-between bg-white border-b border-neutral-200 px-4 h-14 shrink-0">
522
- <div className="flex items-center gap-3">
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
- </div>
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 button */}
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 button */}
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 mx-1">|</span>
589
-
590
- {store.saveError && (
591
- <span className="text-xs text-red-500">
592
- {store.saveError}
593
- </span>
594
- )}
595
- {store.lastSavedAt && !store.isDirty && (
596
- <span className="text-xs text-neutral-400">
597
- Saved {new Date(store.lastSavedAt).toLocaleTimeString()}
598
- </span>
599
- )}
600
- <span className="text-xs text-neutral-300">
601
- Ctrl+S
602
- </span>
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="w-full rounded-xl py-3 text-xs font-medium transition-colors"
816
+ className="rounded-full text-[10px] font-medium transition-all hover:scale-105"
823
817
  style={{
824
- background: "linear-gradient(170deg, rgba(38,38,48,0.95) 0%, rgba(28,28,36,0.97) 100%)",
825
- color: "rgba(200,160,220,0.8)",
826
- boxShadow: "0 2px 8px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.04)",
827
- border: "1px solid rgba(255,255,255,0.06)",
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
- const existingRegistry = await client.fetch(
108
- `*[_type == "assetRegistry"][0]{ assets }`
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
- await writeClient
233
- .patch("assetRegistry")
234
- .set({
235
- assets: mergedAssets,
236
- scan_status: "ready",
237
- scan_error: "",
238
- last_scanned_at: now,
239
- })
240
- .commit();
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 — user can configure CORS manually in Cloudflare dashboard
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
- // Copy to new key
106
- await s3.send(
107
- new CopyObjectCommand({
108
- Bucket: bucket,
109
- CopySource: `${bucket}/${objKey}`,
110
- Key: newObjKey,
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
- // Delete old key
115
- await s3.send(
116
- new DeleteObjectCommand({ Bucket: bucket, Key: objKey })
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", { oldKey, newKey, isFolder, count: renamedPairs.length });
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
  }