@morphika/andami 0.1.2
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/LICENSE +21 -0
- package/README.md +50 -0
- package/admin/assets.ts +4 -0
- package/admin/database.ts +4 -0
- package/admin/index.ts +6 -0
- package/admin/login.ts +4 -0
- package/admin/navigation.ts +4 -0
- package/admin/pages-editor.ts +4 -0
- package/admin/pages.ts +4 -0
- package/admin/projects-editor.ts +4 -0
- package/admin/projects.ts +4 -0
- package/admin/settings.ts +4 -0
- package/admin/setup.ts +4 -0
- package/admin/storage.ts +4 -0
- package/admin/styles.ts +4 -0
- package/app/(site)/[slug]/loading.tsx +20 -0
- package/app/(site)/[slug]/page.tsx +83 -0
- package/app/(site)/error.tsx +32 -0
- package/app/(site)/layout.tsx +53 -0
- package/app/(site)/loading.tsx +20 -0
- package/app/(site)/not-found.tsx +41 -0
- package/app/(site)/page.tsx +43 -0
- package/app/(site)/preview/page.tsx +99 -0
- package/app/(site)/work/[slug]/loading.tsx +23 -0
- package/app/(site)/work/[slug]/page.tsx +84 -0
- package/app/admin/assets/page.tsx +573 -0
- package/app/admin/database/page.tsx +302 -0
- package/app/admin/error.tsx +53 -0
- package/app/admin/layout.tsx +273 -0
- package/app/admin/login/page.tsx +88 -0
- package/app/admin/navigation/page.tsx +157 -0
- package/app/admin/page.tsx +17 -0
- package/app/admin/pages/[slug]/page.tsx +849 -0
- package/app/admin/pages/page.tsx +588 -0
- package/app/admin/projects/[slug]/page.tsx +3 -0
- package/app/admin/projects/page.tsx +669 -0
- package/app/admin/settings/page.tsx +132 -0
- package/app/admin/setup/page.tsx +64 -0
- package/app/admin/storage/page.tsx +518 -0
- package/app/admin/styles/page.tsx +243 -0
- package/app/api/admin/assets/file/route.ts +81 -0
- package/app/api/admin/assets/health/route.ts +170 -0
- package/app/api/admin/assets/register/route.ts +163 -0
- package/app/api/admin/assets/registry/route.ts +98 -0
- package/app/api/admin/assets/relink/confirm/route.ts +242 -0
- package/app/api/admin/assets/relink/route.ts +202 -0
- package/app/api/admin/assets/scan/route.ts +271 -0
- package/app/api/admin/auth/route.ts +160 -0
- package/app/api/admin/custom-sections/[slug]/route.ts +159 -0
- package/app/api/admin/custom-sections/route.ts +127 -0
- package/app/api/admin/database/route.ts +53 -0
- package/app/api/admin/pages/[slug]/duplicate/route.ts +91 -0
- package/app/api/admin/pages/[slug]/route.ts +617 -0
- package/app/api/admin/pages/[slug]/set-home/route.ts +76 -0
- package/app/api/admin/pages/route.ts +129 -0
- package/app/api/admin/preview/route.ts +53 -0
- package/app/api/admin/r2/connect/route.ts +181 -0
- package/app/api/admin/r2/delete/route.ts +198 -0
- package/app/api/admin/r2/disconnect/route.ts +42 -0
- package/app/api/admin/r2/rename/route.ts +265 -0
- package/app/api/admin/r2/status/route.ts +106 -0
- package/app/api/admin/r2/upload-url/route.ts +148 -0
- package/app/api/admin/revalidate/route.ts +55 -0
- package/app/api/admin/settings/route.ts +279 -0
- package/app/api/admin/setup/complete/route.ts +51 -0
- package/app/api/admin/setup/route.ts +118 -0
- package/app/api/admin/storage/switch/route.ts +117 -0
- package/app/api/admin/styles/fonts/route.ts +97 -0
- package/app/api/admin/styles/route.ts +304 -0
- package/app/api/assets/[...path]/route.ts +98 -0
- package/app/api/custom-sections/[id]/route.ts +43 -0
- package/app/api/draft-mode/disable/route.ts +10 -0
- package/app/api/draft-mode/enable/route.ts +26 -0
- package/app/api/projects/route.ts +42 -0
- package/app/api/styles/route.ts +88 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +7 -0
- package/app/layout.tsx +53 -0
- package/app/robots.ts +17 -0
- package/app/sitemap.ts +48 -0
- package/app/studio/[[...index]]/page.tsx +8 -0
- package/components/admin/MetadataEditor.tsx +173 -0
- package/components/admin/PublishToggle.tsx +130 -0
- package/components/admin/icons.tsx +40 -0
- package/components/admin/nav-builder/NavBuilder.tsx +182 -0
- package/components/admin/nav-builder/NavBuilderGrid.tsx +326 -0
- package/components/admin/nav-builder/NavGeneralSettings.tsx +275 -0
- package/components/admin/nav-builder/NavGridCell.tsx +48 -0
- package/components/admin/nav-builder/NavGridItem.tsx +189 -0
- package/components/admin/nav-builder/NavItemSettings.tsx +288 -0
- package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -0
- package/components/admin/nav-builder/NavLivePreview.tsx +125 -0
- package/components/admin/nav-builder/NavSettingsFields.tsx +248 -0
- package/components/admin/nav-builder/NavSettingsPanel.tsx +127 -0
- package/components/admin/nav-builder/index.ts +10 -0
- package/components/admin/nav-builder/nav-builder-utils.ts +238 -0
- package/components/admin/setup-wizard/BrandingStep.tsx +218 -0
- package/components/admin/setup-wizard/DatabaseStep.tsx +331 -0
- package/components/admin/setup-wizard/DoneStep.tsx +187 -0
- package/components/admin/setup-wizard/SetupWizard.tsx +166 -0
- package/components/admin/setup-wizard/StorageStep.tsx +308 -0
- package/components/admin/setup-wizard/WelcomeStep.tsx +96 -0
- package/components/admin/setup-wizard/index.ts +9 -0
- package/components/admin/styles/ColorsEditor.tsx +214 -0
- package/components/admin/styles/FontsEditor.tsx +258 -0
- package/components/admin/styles/GridLayoutEditor.tsx +292 -0
- package/components/admin/styles/LinksButtonsEditor.tsx +120 -0
- package/components/admin/styles/TypographyEditor.tsx +266 -0
- package/components/admin/styles/index.ts +9 -0
- package/components/admin/styles/shared.tsx +68 -0
- package/components/blocks/BlockRenderer.tsx +404 -0
- package/components/blocks/ButtonBlockRenderer.tsx +52 -0
- package/components/blocks/CoverBlockRenderer.tsx +239 -0
- package/components/blocks/CustomSectionInstanceRenderer.tsx +82 -0
- package/components/blocks/EnterAnimationWrapper.tsx +140 -0
- package/components/blocks/HoverAnimationWrapper.tsx +308 -0
- package/components/blocks/ImageBlockRenderer.tsx +61 -0
- package/components/blocks/ImageGridBlockRenderer.tsx +545 -0
- package/components/blocks/PageBackground.tsx +28 -0
- package/components/blocks/PageNavAnimation.tsx +35 -0
- package/components/blocks/PageNavColor.tsx +24 -0
- package/components/blocks/PageRenderer.tsx +142 -0
- package/components/blocks/ParallaxGroupRenderer.tsx +448 -0
- package/components/blocks/ParallaxSlideRenderer.tsx +175 -0
- package/components/blocks/ProjectGridBlockRenderer.tsx +556 -0
- package/components/blocks/SectionRenderer.tsx +170 -0
- package/components/blocks/SectionV2Renderer.tsx +330 -0
- package/components/blocks/ShaderCanvas.tsx +392 -0
- package/components/blocks/SpacerBlockRenderer.tsx +17 -0
- package/components/blocks/TextBlockRenderer.tsx +87 -0
- package/components/blocks/TypewriterRichText.tsx +464 -0
- package/components/blocks/TypewriterWrapper.tsx +149 -0
- package/components/blocks/VideoBlockRenderer.tsx +304 -0
- package/components/blocks/index.ts +2 -0
- package/components/builder/AssetBrowser.tsx +2 -0
- package/components/builder/BlockLivePreview.tsx +101 -0
- package/components/builder/BlockTypePicker.tsx +178 -0
- package/components/builder/BuilderCanvas.tsx +354 -0
- package/components/builder/CanvasMinimap.tsx +200 -0
- package/components/builder/CanvasToolbar.tsx +202 -0
- package/components/builder/ColorPicker.tsx +243 -0
- package/components/builder/ColorSwatchPicker.tsx +274 -0
- package/components/builder/ColumnDragContext.tsx +51 -0
- package/components/builder/ColumnDragOverlay.tsx +110 -0
- package/components/builder/CustomSectionInstanceCard.tsx +97 -0
- package/components/builder/DeviceFrame.tsx +123 -0
- package/components/builder/DndWrapper.tsx +337 -0
- package/components/builder/InsertionLines.tsx +186 -0
- package/components/builder/ParallaxGroupCanvas.tsx +228 -0
- package/components/builder/ParallaxSlideHeader.tsx +113 -0
- package/components/builder/ReadOnlyFrame.tsx +417 -0
- package/components/builder/SectionEditorBar.tsx +288 -0
- package/components/builder/SectionTypePicker.tsx +422 -0
- package/components/builder/SectionV2Canvas.tsx +297 -0
- package/components/builder/SectionV2Column.tsx +488 -0
- package/components/builder/SettingsPanel.tsx +911 -0
- package/components/builder/SortableBlock.tsx +230 -0
- package/components/builder/SortableRow.tsx +362 -0
- package/components/builder/VirtualAssetGrid.tsx +397 -0
- package/components/builder/asset-browser/AssetBrowser.tsx +178 -0
- package/components/builder/asset-browser/FileLightbox.tsx +116 -0
- package/components/builder/asset-browser/FolderTreeItem.tsx +55 -0
- package/components/builder/asset-browser/R2BrowserContent.tsx +436 -0
- package/components/builder/asset-browser/R2ContextMenu.tsx +98 -0
- package/components/builder/asset-browser/VideoThumbnail.tsx +63 -0
- package/components/builder/asset-browser/helpers.ts +88 -0
- package/components/builder/asset-browser/index.ts +1 -0
- package/components/builder/asset-browser/types.ts +49 -0
- package/components/builder/asset-browser/useAssetBrowser.ts +344 -0
- package/components/builder/asset-browser/useR2DragDrop.ts +116 -0
- package/components/builder/asset-browser/useR2Operations.ts +189 -0
- package/components/builder/blockStyles.tsx +295 -0
- package/components/builder/editors/ButtonBlockEditor.tsx +184 -0
- package/components/builder/editors/CoverBlockEditor.tsx +488 -0
- package/components/builder/editors/EnterAnimationPicker.tsx +297 -0
- package/components/builder/editors/HoverEffectPicker.tsx +209 -0
- package/components/builder/editors/ImageBlockEditor.tsx +206 -0
- package/components/builder/editors/ImageGridBlockEditor.tsx +386 -0
- package/components/builder/editors/ProjectGridEditor.tsx +648 -0
- package/components/builder/editors/SpacerBlockEditor.tsx +167 -0
- package/components/builder/editors/StaggerSettings.tsx +108 -0
- package/components/builder/editors/TextAlignmentIcons.tsx +39 -0
- package/components/builder/editors/TextBlockEditor.tsx +462 -0
- package/components/builder/editors/TextStylePicker.tsx +183 -0
- package/components/builder/editors/VideoBlockEditor.tsx +278 -0
- package/components/builder/editors/index.ts +10 -0
- package/components/builder/editors/shared.tsx +345 -0
- package/components/builder/hooks/useColumnDrag.ts +472 -0
- package/components/builder/hooks/useColumnResize.ts +221 -0
- package/components/builder/index.ts +12 -0
- package/components/builder/live-preview/LiveButtonPreview.tsx +38 -0
- package/components/builder/live-preview/LiveCoverPreview.tsx +146 -0
- package/components/builder/live-preview/LiveImageGridPreview.tsx +123 -0
- package/components/builder/live-preview/LiveImagePreview.tsx +107 -0
- package/components/builder/live-preview/LiveProjectGridPreview.tsx +1010 -0
- package/components/builder/live-preview/LiveSpacerPreview.tsx +9 -0
- package/components/builder/live-preview/LiveTextEditor.tsx +198 -0
- package/components/builder/live-preview/LiveVideoPreview.tsx +98 -0
- package/components/builder/live-preview/index.ts +10 -0
- package/components/builder/live-preview/shared.tsx +153 -0
- package/components/builder/settings-panel/BlockLayoutTab.tsx +532 -0
- package/components/builder/settings-panel/BlockSettings.tsx +94 -0
- package/components/builder/settings-panel/ColumnV2Settings.tsx +160 -0
- package/components/builder/settings-panel/LayoutTab.tsx +310 -0
- package/components/builder/settings-panel/PageSettings.tsx +200 -0
- package/components/builder/settings-panel/ParallaxGroupSettings.tsx +118 -0
- package/components/builder/settings-panel/ParallaxSlideSettings.tsx +178 -0
- package/components/builder/settings-panel/SectionV2AnimationTab.tsx +103 -0
- package/components/builder/settings-panel/SectionV2LayoutTab.tsx +312 -0
- package/components/builder/settings-panel/SectionV2Settings.tsx +323 -0
- package/components/builder/settings-panel/TRBLInputs.tsx +51 -0
- package/components/builder/settings-panel/index.ts +19 -0
- package/components/builder/settings-panel/responsive-helpers.ts +524 -0
- package/components/ui/CustomCursor.tsx +118 -0
- package/components/ui/NavContentLightbox.tsx +152 -0
- package/components/ui/Navbar.tsx +582 -0
- package/components/ui/PortfolioTracker.tsx +87 -0
- package/components/ui/ScrollToTop.tsx +47 -0
- package/lib/animation/enter-presets.ts +147 -0
- package/lib/animation/enter-resolve.ts +90 -0
- package/lib/animation/enter-types.ts +128 -0
- package/lib/animation/hover-effect-presets.ts +210 -0
- package/lib/animation/hover-effect-types.ts +126 -0
- package/lib/asset-retry.ts +111 -0
- package/lib/assets.ts +92 -0
- package/lib/audit.ts +35 -0
- package/lib/auth-token.ts +94 -0
- package/lib/auth.ts +13 -0
- package/lib/builder/cascade-helpers.ts +51 -0
- package/lib/builder/cascade.ts +533 -0
- package/lib/builder/constants.ts +103 -0
- package/lib/builder/defaults.ts +182 -0
- package/lib/builder/history.ts +48 -0
- package/lib/builder/index.ts +21 -0
- package/lib/builder/layout-styles.ts +344 -0
- package/lib/builder/masonry.ts +166 -0
- package/lib/builder/responsive.ts +156 -0
- package/lib/builder/serializer.ts +845 -0
- package/lib/builder/store-blocks.ts +193 -0
- package/lib/builder/store-canvas.ts +319 -0
- package/lib/builder/store-helpers.ts +490 -0
- package/lib/builder/store-sections.ts +709 -0
- package/lib/builder/store.ts +333 -0
- package/lib/builder/templates.ts +297 -0
- package/lib/builder/types.ts +374 -0
- package/lib/builder/utils.ts +37 -0
- package/lib/color-utils.ts +116 -0
- package/lib/config/index.ts +57 -0
- package/lib/config/types.ts +122 -0
- package/lib/contexts/AssetContext.tsx +79 -0
- package/lib/contexts/NavAnimationContext.tsx +44 -0
- package/lib/contexts/NavColorContext.tsx +38 -0
- package/lib/contexts/PageExitContext.tsx +194 -0
- package/lib/contexts/ThumbStatusContext.tsx +83 -0
- package/lib/csrf-client.ts +34 -0
- package/lib/csrf.ts +68 -0
- package/lib/format-utils.ts +24 -0
- package/lib/hooks/useViewport.ts +42 -0
- package/lib/logger.ts +81 -0
- package/lib/revalidate.ts +23 -0
- package/lib/sanitize.ts +91 -0
- package/lib/sanity/client.ts +8 -0
- package/lib/sanity/queries.ts +486 -0
- package/lib/sanity/types.ts +869 -0
- package/lib/sanity/writeClient.ts +24 -0
- package/lib/security.ts +402 -0
- package/lib/setup/detect.ts +156 -0
- package/lib/shader/glsl/index.ts +27 -0
- package/lib/shader/glsl/pixelate.ts +51 -0
- package/lib/shader/glsl/rgb-shift.ts +45 -0
- package/lib/shader/glsl/ripple.ts +46 -0
- package/lib/shader/glsl/vertex.ts +14 -0
- package/lib/storage/index.ts +211 -0
- package/lib/storage/r2-adapter.ts +286 -0
- package/lib/storage/types.ts +125 -0
- package/lib/styles/provider.tsx +267 -0
- package/lib/thumbnails/generate.ts +151 -0
- package/lib/utils.ts +6 -0
- package/package.json +212 -0
- package/sanity/compose.ts +65 -0
- package/sanity/sanity.config.ts +126 -0
- package/sanity/schemas/assetRegistry.ts +301 -0
- package/sanity/schemas/blocks/blockLayout.ts +90 -0
- package/sanity/schemas/blocks/buttonBlock.ts +82 -0
- package/sanity/schemas/blocks/coverBlock.ts +229 -0
- package/sanity/schemas/blocks/imageBlock.ts +58 -0
- package/sanity/schemas/blocks/imageGridBlock.ts +112 -0
- package/sanity/schemas/blocks/index.ts +9 -0
- package/sanity/schemas/blocks/projectGridBlock.ts +251 -0
- package/sanity/schemas/blocks/spacerBlock.ts +41 -0
- package/sanity/schemas/blocks/textBlock.ts +139 -0
- package/sanity/schemas/blocks/videoBlock.ts +80 -0
- package/sanity/schemas/customSection.ts +69 -0
- package/sanity/schemas/customSectionInstance.ts +163 -0
- package/sanity/schemas/index.ts +111 -0
- package/sanity/schemas/objects/enterAnimationConfig.ts +72 -0
- package/sanity/schemas/objects/hoverEffectConfig.ts +90 -0
- package/sanity/schemas/objects/parallaxGroup.ts +66 -0
- package/sanity/schemas/objects/parallaxSlide.ts +217 -0
- package/sanity/schemas/objects/typewriterConfig.ts +38 -0
- package/sanity/schemas/page.ts +162 -0
- package/sanity/schemas/pageSection.ts +157 -0
- package/sanity/schemas/pageSectionV2.ts +269 -0
- package/sanity/schemas/siteSettings.ts +256 -0
- package/sanity/schemas/siteStyles.ts +212 -0
- package/site/error.ts +4 -0
- package/site/index.ts +8 -0
- package/site/not-found.ts +4 -0
- package/site/page.ts +4 -0
- package/site/preview.ts +4 -0
- package/site/robots.ts +4 -0
- package/site/sitemap.ts +4 -0
- package/site/work.ts +4 -0
- package/studio/index.ts +4 -0
- package/styles/admin.css +85 -0
- package/styles/animations.css +237 -0
- package/styles/base.css +148 -0
- package/styles/globals.css +10 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { client } from "../../../../lib/sanity/client";
|
|
3
|
+
import { writeClient } from "../../../../lib/sanity/writeClient";
|
|
4
|
+
import { allPagesQuery } from "../../../../lib/sanity/queries";
|
|
5
|
+
import { isAdminAuthenticated } from "../../../../lib/auth";
|
|
6
|
+
import { validateCsrf, csrfErrorResponse } from "../../../../lib/csrf";
|
|
7
|
+
import { isBodyTooLarge, MAX_JSON_BODY_SIZE } from "../../../../lib/security";
|
|
8
|
+
import { auditLog } from "../../../../lib/audit";
|
|
9
|
+
import { logger } from "../../../../lib/logger";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* GET /api/admin/pages — List all pages
|
|
13
|
+
*/
|
|
14
|
+
export async function GET() {
|
|
15
|
+
if (!(await isAdminAuthenticated())) {
|
|
16
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const pages = await client.fetch(allPagesQuery);
|
|
21
|
+
return NextResponse.json({ pages });
|
|
22
|
+
} catch (err) {
|
|
23
|
+
logger.error("[Admin:Pages]", "Failed to fetch pages", err);
|
|
24
|
+
return NextResponse.json(
|
|
25
|
+
{ error: "Failed to fetch pages" },
|
|
26
|
+
{ status: 500 }
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* POST /api/admin/pages — Create a new page
|
|
33
|
+
* Body: { title, slug, page_type }
|
|
34
|
+
*/
|
|
35
|
+
export async function POST(request: NextRequest) {
|
|
36
|
+
if (!(await isAdminAuthenticated())) {
|
|
37
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
38
|
+
}
|
|
39
|
+
if (!validateCsrf(request)) {
|
|
40
|
+
return csrfErrorResponse();
|
|
41
|
+
}
|
|
42
|
+
if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
|
|
43
|
+
return NextResponse.json({ error: "Request body too large" }, { status: 413 });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const body = await request.json();
|
|
48
|
+
const { title, slug, page_type, thumbnail_path, cover_video, content_rows } = body;
|
|
49
|
+
|
|
50
|
+
// Validate required fields
|
|
51
|
+
if (!title || !slug) {
|
|
52
|
+
return NextResponse.json(
|
|
53
|
+
{ error: "Missing required fields: title, slug" },
|
|
54
|
+
{ status: 400 }
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Validate title length
|
|
59
|
+
if (typeof title === "string" && title.length > 500) {
|
|
60
|
+
return NextResponse.json(
|
|
61
|
+
{ error: "Title exceeds maximum length of 500 characters" },
|
|
62
|
+
{ status: 400 }
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Validate slug format: lowercase alphanumeric + hyphens only
|
|
67
|
+
if (typeof slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
|
|
68
|
+
return NextResponse.json(
|
|
69
|
+
{ error: "Slug must be lowercase alphanumeric with hyphens only (e.g. 'my-page')" },
|
|
70
|
+
{ status: 400 }
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Validate page_type (defaults to "page" if not provided)
|
|
75
|
+
const resolvedPageType = page_type || "page";
|
|
76
|
+
const validTypes = ["page", "project"];
|
|
77
|
+
if (!validTypes.includes(resolvedPageType)) {
|
|
78
|
+
return NextResponse.json(
|
|
79
|
+
{ error: "Invalid page type" },
|
|
80
|
+
{ status: 400 }
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Check for duplicate slug
|
|
85
|
+
const existing = await client.fetch(
|
|
86
|
+
`*[_type == "page" && slug.current == $slug][0]._id`,
|
|
87
|
+
{ slug }
|
|
88
|
+
);
|
|
89
|
+
if (existing) {
|
|
90
|
+
return NextResponse.json(
|
|
91
|
+
{ error: "A page with this slug already exists" },
|
|
92
|
+
{ status: 409 }
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Create the page document
|
|
97
|
+
const doc = {
|
|
98
|
+
_type: "page",
|
|
99
|
+
title,
|
|
100
|
+
slug: { _type: "slug", current: slug },
|
|
101
|
+
page_type: resolvedPageType,
|
|
102
|
+
is_home: false,
|
|
103
|
+
content_rows: Array.isArray(content_rows) ? content_rows : [],
|
|
104
|
+
page_settings: {
|
|
105
|
+
background_color: "#ffffff",
|
|
106
|
+
text_color: "#000000",
|
|
107
|
+
},
|
|
108
|
+
metadata: {
|
|
109
|
+
seo_title: title,
|
|
110
|
+
seo_description: "",
|
|
111
|
+
},
|
|
112
|
+
draft_mode: true,
|
|
113
|
+
...(thumbnail_path ? { thumbnail_path } : {}),
|
|
114
|
+
...(cover_video ? { cover_video } : {}),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const created = await writeClient.create(doc);
|
|
118
|
+
|
|
119
|
+
auditLog("page.create", { slug, page_type: resolvedPageType, title });
|
|
120
|
+
|
|
121
|
+
return NextResponse.json({ page: created }, { status: 201 });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
logger.error("[Admin:Pages]", "Failed to create page", err);
|
|
124
|
+
return NextResponse.json(
|
|
125
|
+
{ error: "Failed to create page" },
|
|
126
|
+
{ status: 500 }
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { client } from "../../../../lib/sanity/client";
|
|
3
|
+
import { pageBySlugQuery } from "../../../../lib/sanity/queries";
|
|
4
|
+
import { isAdminAuthenticated } from "../../../../lib/auth";
|
|
5
|
+
import { logger } from "../../../../lib/logger";
|
|
6
|
+
import type { Page } from "../../../../lib/sanity/types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Preview API — fetches a page including drafts.
|
|
10
|
+
*
|
|
11
|
+
* Used by the builder "Preview" button to show content that
|
|
12
|
+
* hasn't been published yet.
|
|
13
|
+
*
|
|
14
|
+
* Query params:
|
|
15
|
+
* ?slug=about → fetch by slug (includes drafts)
|
|
16
|
+
*/
|
|
17
|
+
export async function GET(req: NextRequest) {
|
|
18
|
+
if (!(await isAdminAuthenticated())) {
|
|
19
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { searchParams } = new URL(req.url);
|
|
23
|
+
const slug = searchParams.get("slug");
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
let page: Page | null = null;
|
|
27
|
+
|
|
28
|
+
if (slug) {
|
|
29
|
+
// pageBySlugQuery already includes drafts (no draft_mode filter)
|
|
30
|
+
page = await client.fetch<Page>(pageBySlugQuery, { slug });
|
|
31
|
+
} else {
|
|
32
|
+
return NextResponse.json(
|
|
33
|
+
{ error: "Provide ?slug= parameter" },
|
|
34
|
+
{ status: 400 }
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!page) {
|
|
39
|
+
return NextResponse.json(
|
|
40
|
+
{ error: "Page not found" },
|
|
41
|
+
{ status: 404 }
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return NextResponse.json({ page });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
logger.error("[Admin:Preview]", "Preview API error", error);
|
|
48
|
+
return NextResponse.json(
|
|
49
|
+
{ error: "Failed to fetch page" },
|
|
50
|
+
{ status: 500 }
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { S3Client, HeadBucketCommand, PutBucketCorsCommand } from "@aws-sdk/client-s3";
|
|
3
|
+
import { isAdminAuthenticated } from "../../../../../lib/auth";
|
|
4
|
+
import { writeClient } from "../../../../../lib/sanity/writeClient";
|
|
5
|
+
import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
|
|
6
|
+
import { encryptToken, isBodyTooLarge, MAX_JSON_BODY_SIZE, jsonError } from "../../../../../lib/security";
|
|
7
|
+
import { logger } from "../../../../../lib/logger";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* POST /api/admin/r2/connect — Validate and store R2 credentials.
|
|
11
|
+
*
|
|
12
|
+
* 1. Receives { endpoint, bucketName, accessKeyId, secretAccessKey, publicUrl }.
|
|
13
|
+
* 2. Validates all fields are present and well-formed.
|
|
14
|
+
* 3. Tests connectivity by sending a HEAD request to the public URL.
|
|
15
|
+
* 4. Encrypts accessKeyId and secretAccessKey with AES-GCM.
|
|
16
|
+
* 5. Stores everything in the assetRegistry document.
|
|
17
|
+
*
|
|
18
|
+
* Does NOT switch the active storage_provider — that's Phase 4.
|
|
19
|
+
*/
|
|
20
|
+
export async function POST(request: NextRequest) {
|
|
21
|
+
if (!(await isAdminAuthenticated())) {
|
|
22
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
23
|
+
}
|
|
24
|
+
if (!validateCsrf(request)) {
|
|
25
|
+
return csrfErrorResponse();
|
|
26
|
+
}
|
|
27
|
+
if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
|
|
28
|
+
return jsonError("Request body too large", 413);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const body = await request.json();
|
|
33
|
+
const { endpoint, bucketName, accessKeyId, secretAccessKey, publicUrl } = body;
|
|
34
|
+
|
|
35
|
+
// ── Validate required fields ──
|
|
36
|
+
if (!endpoint || typeof endpoint !== "string") {
|
|
37
|
+
return jsonError("S3 endpoint is required", 400);
|
|
38
|
+
}
|
|
39
|
+
if (!bucketName || typeof bucketName !== "string") {
|
|
40
|
+
return jsonError("Bucket name is required", 400);
|
|
41
|
+
}
|
|
42
|
+
if (!accessKeyId || typeof accessKeyId !== "string") {
|
|
43
|
+
return jsonError("Access Key ID is required", 400);
|
|
44
|
+
}
|
|
45
|
+
if (!secretAccessKey || typeof secretAccessKey !== "string") {
|
|
46
|
+
return jsonError("Secret Access Key is required", 400);
|
|
47
|
+
}
|
|
48
|
+
if (!publicUrl || typeof publicUrl !== "string") {
|
|
49
|
+
return jsonError("Public bucket URL is required", 400);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Validate URL formats ──
|
|
53
|
+
try {
|
|
54
|
+
const endpointUrl = new URL(endpoint);
|
|
55
|
+
if (endpointUrl.protocol !== "https:") {
|
|
56
|
+
return jsonError("S3 endpoint must use HTTPS", 400);
|
|
57
|
+
}
|
|
58
|
+
// #13: Whitelist known Cloudflare R2 endpoint patterns to prevent credential theft
|
|
59
|
+
const hostname = endpointUrl.hostname.toLowerCase();
|
|
60
|
+
if (!hostname.endsWith(".r2.cloudflarestorage.com") && !hostname.endsWith(".cloudflare.com")) {
|
|
61
|
+
return jsonError(
|
|
62
|
+
"S3 endpoint must be a Cloudflare R2 endpoint (*.r2.cloudflarestorage.com). Custom endpoints are not allowed for security.",
|
|
63
|
+
400
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
return jsonError("Invalid S3 endpoint URL", 400);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const pubUrl = new URL(publicUrl);
|
|
72
|
+
if (pubUrl.protocol !== "https:" && pubUrl.protocol !== "http:") {
|
|
73
|
+
return jsonError("Public URL must use HTTP or HTTPS", 400);
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
return jsonError("Invalid public bucket URL", 400);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Test connection — HEAD request to the public URL ──
|
|
80
|
+
// A properly configured public R2 bucket returns 200 or 404 (no index)
|
|
81
|
+
// but NOT a connection error or auth error (403)
|
|
82
|
+
try {
|
|
83
|
+
const testRes = await fetch(publicUrl.replace(/\/$/, ""), {
|
|
84
|
+
method: "HEAD",
|
|
85
|
+
signal: AbortSignal.timeout(8000),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (!testRes.ok && testRes.status !== 404) {
|
|
89
|
+
return jsonError(
|
|
90
|
+
`Connection test failed: public URL returned HTTP ${testRes.status}. Ensure the bucket has public access enabled.`,
|
|
91
|
+
400
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return jsonError(
|
|
96
|
+
"Cannot reach public URL. Check the URL and try again.",
|
|
97
|
+
400
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// #17: Test S3 API credentials (not just public URL) using HeadBucket
|
|
102
|
+
try {
|
|
103
|
+
const testS3 = new S3Client({
|
|
104
|
+
region: "auto",
|
|
105
|
+
endpoint: endpoint.trim().replace(/\/$/, ""),
|
|
106
|
+
credentials: {
|
|
107
|
+
accessKeyId: accessKeyId.trim(),
|
|
108
|
+
secretAccessKey: secretAccessKey.trim(),
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
await testS3.send(new HeadBucketCommand({ Bucket: bucketName.trim() }));
|
|
112
|
+
|
|
113
|
+
// ── Configure CORS for browser-direct uploads ──
|
|
114
|
+
// Presigned PUT URLs require the bucket to allow cross-origin requests
|
|
115
|
+
// from the Vercel deployment origin. We allow all origins with * because
|
|
116
|
+
// the presigned URL itself is the auth gate — only holders of a valid
|
|
117
|
+
// signed URL can upload. This also covers preview deploys and localhost.
|
|
118
|
+
try {
|
|
119
|
+
await testS3.send(
|
|
120
|
+
new PutBucketCorsCommand({
|
|
121
|
+
Bucket: bucketName.trim(),
|
|
122
|
+
CORSConfiguration: {
|
|
123
|
+
CORSRules: [
|
|
124
|
+
{
|
|
125
|
+
AllowedOrigins: ["*"],
|
|
126
|
+
AllowedMethods: ["GET", "PUT", "HEAD"],
|
|
127
|
+
AllowedHeaders: ["*"],
|
|
128
|
+
ExposeHeaders: ["ETag", "Content-Length"],
|
|
129
|
+
MaxAgeSeconds: 86400,
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
);
|
|
135
|
+
} catch (corsErr) {
|
|
136
|
+
logger.warn("[Admin:R2]", "Failed to auto-configure CORS on R2 bucket", corsErr);
|
|
137
|
+
// Non-fatal — user can configure CORS manually in Cloudflare dashboard
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
testS3.destroy();
|
|
141
|
+
} catch (s3Err) {
|
|
142
|
+
return jsonError(
|
|
143
|
+
`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.`,
|
|
144
|
+
400
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Encrypt credentials ──
|
|
149
|
+
const encryptedAccessKeyId = await encryptToken(accessKeyId.trim());
|
|
150
|
+
const encryptedSecretAccessKey = await encryptToken(secretAccessKey.trim());
|
|
151
|
+
|
|
152
|
+
// #38: Normalize publicUrl using URL constructor for consistent format
|
|
153
|
+
const normalizedPublicUrl = new URL(publicUrl.trim()).href.replace(/\/+$/, "");
|
|
154
|
+
const normalizedEndpoint = new URL(endpoint.trim()).href.replace(/\/+$/, "");
|
|
155
|
+
|
|
156
|
+
// ── Store in Sanity ──
|
|
157
|
+
await writeClient
|
|
158
|
+
.patch("assetRegistry")
|
|
159
|
+
.set({
|
|
160
|
+
r2_bucket_url: normalizedPublicUrl,
|
|
161
|
+
r2_endpoint: normalizedEndpoint,
|
|
162
|
+
r2_bucket_name: bucketName.trim(),
|
|
163
|
+
r2_access_key_id: encryptedAccessKeyId,
|
|
164
|
+
r2_secret_access_key: encryptedSecretAccessKey,
|
|
165
|
+
r2_connected_at: new Date().toISOString(),
|
|
166
|
+
})
|
|
167
|
+
.commit();
|
|
168
|
+
|
|
169
|
+
return NextResponse.json({
|
|
170
|
+
success: true,
|
|
171
|
+
bucketName: bucketName.trim(),
|
|
172
|
+
publicUrl: publicUrl.trim().replace(/\/$/, ""),
|
|
173
|
+
});
|
|
174
|
+
} catch (err) {
|
|
175
|
+
logger.error("[Admin:R2]", "Failed to connect R2", err);
|
|
176
|
+
return NextResponse.json(
|
|
177
|
+
{ error: "Failed to store R2 configuration" },
|
|
178
|
+
{ status: 500 }
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { S3Client, DeleteObjectCommand, ListObjectsV2Command, DeleteObjectsCommand } from "@aws-sdk/client-s3";
|
|
3
|
+
import { isAdminAuthenticated } from "../../../../../lib/auth";
|
|
4
|
+
import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
|
|
5
|
+
import { isBodyTooLarge, MAX_JSON_BODY_SIZE, jsonError, isValidAssetPath, checkRateLimit } from "../../../../../lib/security";
|
|
6
|
+
import { client } from "../../../../../lib/sanity/client";
|
|
7
|
+
import { writeClient } from "../../../../../lib/sanity/writeClient";
|
|
8
|
+
import { decryptToken } from "../../../../../lib/security";
|
|
9
|
+
import { auditLog } from "../../../../../lib/audit";
|
|
10
|
+
import { logger } from "../../../../../lib/logger";
|
|
11
|
+
import { isRasterImage, thumbKeyForPath } from "../../../../../lib/thumbnails/generate";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* POST /api/admin/r2/delete — Delete a file or folder from R2.
|
|
15
|
+
*
|
|
16
|
+
* Body: { key: string, isFolder?: boolean }
|
|
17
|
+
* - key: relative path (e.g. "Projects/hero.jpg" or "Projects/MyFolder")
|
|
18
|
+
* - isFolder: if true, deletes all objects with the key as prefix
|
|
19
|
+
*
|
|
20
|
+
* Also removes matching entries from the Sanity asset registry.
|
|
21
|
+
* For raster images, also deletes the corresponding _thumbs/ thumbnail (best-effort).
|
|
22
|
+
*/
|
|
23
|
+
export async function POST(request: NextRequest) {
|
|
24
|
+
if (!(await isAdminAuthenticated())) {
|
|
25
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
26
|
+
}
|
|
27
|
+
if (!validateCsrf(request)) {
|
|
28
|
+
return csrfErrorResponse();
|
|
29
|
+
}
|
|
30
|
+
if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
|
|
31
|
+
return jsonError("Request body too large", 413);
|
|
32
|
+
}
|
|
33
|
+
// #32: Rate limit R2 delete operations (30 per minute)
|
|
34
|
+
const ip = request.headers.get("x-forwarded-for") || "unknown";
|
|
35
|
+
if (!checkRateLimit(`r2-delete:${ip}`, 30, 60_000)) {
|
|
36
|
+
return jsonError("Too many requests. Please slow down.", 429);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const body = await request.json();
|
|
41
|
+
const { key, isFolder } = body;
|
|
42
|
+
|
|
43
|
+
if (!key || typeof key !== "string") {
|
|
44
|
+
return jsonError("Key is required", 400);
|
|
45
|
+
}
|
|
46
|
+
// #1, #16: Always validate path regardless of isFolder flag — prevents path traversal
|
|
47
|
+
if (!isValidAssetPath(key)) {
|
|
48
|
+
return jsonError("Invalid path", 400);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Fetch R2 credentials
|
|
52
|
+
const registry = await client.fetch(
|
|
53
|
+
`*[_type == "assetRegistry"][0]{
|
|
54
|
+
_id, r2_endpoint, r2_bucket_name, r2_access_key_id, r2_secret_access_key, assets
|
|
55
|
+
}`
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
if (!registry?.r2_endpoint || !registry?.r2_bucket_name) {
|
|
59
|
+
return jsonError("R2 is not connected", 400);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const accessKeyId = await decryptToken(registry.r2_access_key_id);
|
|
63
|
+
const secretAccessKey = await decryptToken(registry.r2_secret_access_key);
|
|
64
|
+
|
|
65
|
+
const s3 = new S3Client({
|
|
66
|
+
region: "auto",
|
|
67
|
+
endpoint: registry.r2_endpoint,
|
|
68
|
+
credentials: { accessKeyId, secretAccessKey },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
let deletedKeys: string[] = [];
|
|
72
|
+
|
|
73
|
+
if (isFolder) {
|
|
74
|
+
// List all objects with this prefix and delete them
|
|
75
|
+
const prefix = key.replace(/\/+$/, "") + "/";
|
|
76
|
+
let continuationToken: string | undefined;
|
|
77
|
+
|
|
78
|
+
do {
|
|
79
|
+
const list = await s3.send(
|
|
80
|
+
new ListObjectsV2Command({
|
|
81
|
+
Bucket: registry.r2_bucket_name,
|
|
82
|
+
Prefix: prefix,
|
|
83
|
+
ContinuationToken: continuationToken,
|
|
84
|
+
MaxKeys: 1000,
|
|
85
|
+
})
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const objects = (list.Contents || []).map((obj) => ({ Key: obj.Key! }));
|
|
89
|
+
if (objects.length > 0) {
|
|
90
|
+
// #15: Check for partial failures in batch delete
|
|
91
|
+
const deleteResult = await s3.send(
|
|
92
|
+
new DeleteObjectsCommand({
|
|
93
|
+
Bucket: registry.r2_bucket_name,
|
|
94
|
+
Delete: { Objects: objects },
|
|
95
|
+
})
|
|
96
|
+
);
|
|
97
|
+
const errors = deleteResult.Errors || [];
|
|
98
|
+
if (errors.length > 0) {
|
|
99
|
+
logger.error("[Admin:R2]", `Partial failure: ${errors.length} of ${objects.length} objects failed`, errors);
|
|
100
|
+
}
|
|
101
|
+
// Only count successfully deleted keys
|
|
102
|
+
const failedKeys = new Set(errors.map((e) => e.Key));
|
|
103
|
+
const succeeded = objects.filter((o) => !failedKeys.has(o.Key));
|
|
104
|
+
deletedKeys.push(...succeeded.map((o) => o.Key));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
continuationToken = list.NextContinuationToken;
|
|
108
|
+
} while (continuationToken);
|
|
109
|
+
|
|
110
|
+
// ── Thumbnail propagation: also delete _thumbs/{folder}/ contents ──
|
|
111
|
+
// Best-effort — failures here don't affect the main operation.
|
|
112
|
+
try {
|
|
113
|
+
const thumbPrefix = `_thumbs/${prefix}`;
|
|
114
|
+
let thumbContinuation: string | undefined;
|
|
115
|
+
|
|
116
|
+
do {
|
|
117
|
+
const thumbList = await s3.send(
|
|
118
|
+
new ListObjectsV2Command({
|
|
119
|
+
Bucket: registry.r2_bucket_name,
|
|
120
|
+
Prefix: thumbPrefix,
|
|
121
|
+
ContinuationToken: thumbContinuation,
|
|
122
|
+
MaxKeys: 1000,
|
|
123
|
+
})
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const thumbObjects = (thumbList.Contents || []).map((obj) => ({ Key: obj.Key! }));
|
|
127
|
+
if (thumbObjects.length > 0) {
|
|
128
|
+
await s3.send(
|
|
129
|
+
new DeleteObjectsCommand({
|
|
130
|
+
Bucket: registry.r2_bucket_name,
|
|
131
|
+
Delete: { Objects: thumbObjects },
|
|
132
|
+
})
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
thumbContinuation = thumbList.NextContinuationToken;
|
|
137
|
+
} while (thumbContinuation);
|
|
138
|
+
} catch (thumbErr) {
|
|
139
|
+
logger.warn("[Admin:R2]", "Failed to delete folder thumbnails (best-effort)", thumbErr);
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
// Delete single file
|
|
143
|
+
await s3.send(
|
|
144
|
+
new DeleteObjectCommand({
|
|
145
|
+
Bucket: registry.r2_bucket_name,
|
|
146
|
+
Key: key,
|
|
147
|
+
})
|
|
148
|
+
);
|
|
149
|
+
deletedKeys = [key];
|
|
150
|
+
|
|
151
|
+
// ── Thumbnail propagation: delete _thumbs/ counterpart for raster images ──
|
|
152
|
+
// S3 DeleteObject on a non-existent key is a no-op, so no need to check existence.
|
|
153
|
+
if (isRasterImage(key)) {
|
|
154
|
+
try {
|
|
155
|
+
const thumbKey = thumbKeyForPath(key);
|
|
156
|
+
await s3.send(
|
|
157
|
+
new DeleteObjectCommand({
|
|
158
|
+
Bucket: registry.r2_bucket_name,
|
|
159
|
+
Key: thumbKey,
|
|
160
|
+
})
|
|
161
|
+
);
|
|
162
|
+
} catch (thumbErr) {
|
|
163
|
+
logger.warn("[Admin:R2]", "Failed to delete thumbnail (best-effort)", thumbErr);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Remove matching entries from Sanity registry
|
|
169
|
+
if (deletedKeys.length > 0 && registry.assets?.length) {
|
|
170
|
+
const keysToRemove = new Set(deletedKeys);
|
|
171
|
+
const toUnset = registry.assets
|
|
172
|
+
.filter((a: { path: string; _key: string }) => keysToRemove.has(a.path))
|
|
173
|
+
.map((a: { _key: string }) => `assets[_key=="${a._key}"]`);
|
|
174
|
+
|
|
175
|
+
if (toUnset.length > 0) {
|
|
176
|
+
await writeClient
|
|
177
|
+
.patch(registry._id)
|
|
178
|
+
.unset(toUnset)
|
|
179
|
+
.commit();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
auditLog("r2.delete", { key, isFolder, deletedCount: deletedKeys.length });
|
|
184
|
+
|
|
185
|
+
return NextResponse.json({
|
|
186
|
+
success: true,
|
|
187
|
+
deletedCount: deletedKeys.length,
|
|
188
|
+
});
|
|
189
|
+
} catch (err) {
|
|
190
|
+
// #5: Gracefully handle corrupted encrypted tokens
|
|
191
|
+
if (err instanceof Error && (err.message.includes("decrypt") || err.message.includes("atob") || err.message.includes("Invalid encrypted"))) {
|
|
192
|
+
logger.error("[Admin:R2]", "Failed to decrypt R2 credentials", err);
|
|
193
|
+
return jsonError("R2 credentials are corrupted. Please reconnect R2 in /admin/storage.", 500);
|
|
194
|
+
}
|
|
195
|
+
logger.error("[Admin:R2]", "Failed to delete from R2", err);
|
|
196
|
+
return jsonError("Failed to delete", 500);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { isAdminAuthenticated } from "../../../../../lib/auth";
|
|
3
|
+
import { writeClient } from "../../../../../lib/sanity/writeClient";
|
|
4
|
+
import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
|
|
5
|
+
import { logger } from "../../../../../lib/logger";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* POST /api/admin/r2/disconnect — Clear R2 credentials from the registry.
|
|
9
|
+
*
|
|
10
|
+
* Removes all R2-specific fields. Does NOT change storage_provider —
|
|
11
|
+
* if R2 was the active provider, the caller should handle that separately.
|
|
12
|
+
*/
|
|
13
|
+
export async function POST(request: NextRequest) {
|
|
14
|
+
if (!(await isAdminAuthenticated())) {
|
|
15
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
16
|
+
}
|
|
17
|
+
if (!validateCsrf(request)) {
|
|
18
|
+
return csrfErrorResponse();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
await writeClient
|
|
23
|
+
.patch("assetRegistry")
|
|
24
|
+
.unset([
|
|
25
|
+
"r2_bucket_url",
|
|
26
|
+
"r2_access_key_id",
|
|
27
|
+
"r2_secret_access_key",
|
|
28
|
+
"r2_endpoint",
|
|
29
|
+
"r2_bucket_name",
|
|
30
|
+
"r2_connected_at",
|
|
31
|
+
])
|
|
32
|
+
.commit();
|
|
33
|
+
|
|
34
|
+
return NextResponse.json({ success: true });
|
|
35
|
+
} catch (err) {
|
|
36
|
+
logger.error("[Admin:R2]", "Failed to disconnect R2", err);
|
|
37
|
+
return NextResponse.json(
|
|
38
|
+
{ error: "Failed to disconnect R2" },
|
|
39
|
+
{ status: 500 }
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|