@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,279 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { isAdminAuthenticated } from "../../../../lib/auth";
|
|
3
|
+
import { client } from "../../../../lib/sanity/client";
|
|
4
|
+
import { writeClient } from "../../../../lib/sanity/writeClient";
|
|
5
|
+
import { siteSettingsQuery } from "../../../../lib/sanity/queries";
|
|
6
|
+
import { isSafeUrl, isBodyTooLarge, MAX_JSON_BODY_SIZE } from "../../../../lib/security";
|
|
7
|
+
import { validateCsrf, csrfErrorResponse } from "../../../../lib/csrf";
|
|
8
|
+
import { auditLog } from "../../../../lib/audit";
|
|
9
|
+
import { getSiteConfig } from "../../../../lib/config";
|
|
10
|
+
import { logger } from "../../../../lib/logger";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* GET /api/admin/settings — fetch siteSettings document
|
|
14
|
+
* POST /api/admin/settings — update siteSettings (authenticated)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const SETTINGS_ID = "siteSettings";
|
|
18
|
+
const cfg = getSiteConfig();
|
|
19
|
+
|
|
20
|
+
export async function GET() {
|
|
21
|
+
const authenticated = await isAdminAuthenticated();
|
|
22
|
+
if (!authenticated) {
|
|
23
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
let settings = await client.fetch(siteSettingsQuery);
|
|
28
|
+
|
|
29
|
+
// Auto-create if missing
|
|
30
|
+
if (!settings) {
|
|
31
|
+
await writeClient.createIfNotExists({
|
|
32
|
+
_id: SETTINGS_ID,
|
|
33
|
+
_type: "siteSettings",
|
|
34
|
+
nav_items: [],
|
|
35
|
+
default_title: cfg.defaults.metaTitle,
|
|
36
|
+
});
|
|
37
|
+
settings = await client.fetch(siteSettingsQuery);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return NextResponse.json({ settings });
|
|
41
|
+
} catch (error) {
|
|
42
|
+
logger.error("[Admin:Settings]", "Failed to fetch settings:", error);
|
|
43
|
+
return NextResponse.json(
|
|
44
|
+
{ error: "Failed to fetch settings" },
|
|
45
|
+
{ status: 500 }
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function POST(request: NextRequest) {
|
|
51
|
+
const authenticated = await isAdminAuthenticated();
|
|
52
|
+
if (!authenticated) {
|
|
53
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
54
|
+
}
|
|
55
|
+
if (!validateCsrf(request)) {
|
|
56
|
+
return csrfErrorResponse();
|
|
57
|
+
}
|
|
58
|
+
if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
|
|
59
|
+
return NextResponse.json({ error: "Request body too large" }, { status: 413 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const body = await request.json();
|
|
64
|
+
const { section, data } = body;
|
|
65
|
+
|
|
66
|
+
if (!section || !data) {
|
|
67
|
+
return NextResponse.json(
|
|
68
|
+
{ error: "Missing section or data" },
|
|
69
|
+
{ status: 400 }
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Validate section name
|
|
74
|
+
const validSections = ["navigation", "nav_design", "metadata", "assets"];
|
|
75
|
+
if (!validSections.includes(section)) {
|
|
76
|
+
return NextResponse.json(
|
|
77
|
+
{ error: "Invalid section" },
|
|
78
|
+
{ status: 400 }
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Build patch based on section
|
|
83
|
+
let patch: Record<string, unknown> = {};
|
|
84
|
+
|
|
85
|
+
switch (section) {
|
|
86
|
+
case "navigation": {
|
|
87
|
+
// Validate nav_items structure
|
|
88
|
+
if (!Array.isArray(data.nav_items)) {
|
|
89
|
+
return NextResponse.json(
|
|
90
|
+
{ error: "nav_items must be an array" },
|
|
91
|
+
{ status: 400 }
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
// Check nav_items array length
|
|
95
|
+
if (data.nav_items.length > 50) {
|
|
96
|
+
return NextResponse.json(
|
|
97
|
+
{ error: "nav_items exceeds maximum of 50 items" },
|
|
98
|
+
{ status: 400 }
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
for (const item of data.nav_items) {
|
|
102
|
+
if (!item.label || typeof item.label !== "string") {
|
|
103
|
+
return NextResponse.json(
|
|
104
|
+
{ error: "Each nav item must have a label" },
|
|
105
|
+
{ status: 400 }
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (item.label.length > 1000) {
|
|
109
|
+
return NextResponse.json(
|
|
110
|
+
{ error: "Nav item label exceeds maximum length of 1000 characters" },
|
|
111
|
+
{ status: 400 }
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (!["internal", "external", "content"].includes(item.link_type)) {
|
|
115
|
+
return NextResponse.json(
|
|
116
|
+
{ error: "link_type must be 'internal', 'external', or 'content'" },
|
|
117
|
+
{ status: 400 }
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
// Validate external URLs — reject javascript:, data:, vbscript: etc.
|
|
121
|
+
if (item.link_type === "external" && item.external_url) {
|
|
122
|
+
if (!isSafeUrl(item.external_url)) {
|
|
123
|
+
return NextResponse.json(
|
|
124
|
+
{ error: "External URL uses a disallowed protocol" },
|
|
125
|
+
{ status: 400 }
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Validate content embed URLs
|
|
130
|
+
if (item.link_type === "content" && item.content_type === "video-embed" && item.content_url) {
|
|
131
|
+
if (!isSafeUrl(item.content_url)) {
|
|
132
|
+
return NextResponse.json(
|
|
133
|
+
{ error: "Content embed URL uses a disallowed protocol" },
|
|
134
|
+
{ status: 400 }
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
patch = {
|
|
140
|
+
nav_items: data.nav_items.map(
|
|
141
|
+
(item: {
|
|
142
|
+
_key?: string;
|
|
143
|
+
type?: string;
|
|
144
|
+
label: string;
|
|
145
|
+
logo_image?: string;
|
|
146
|
+
link_type: string;
|
|
147
|
+
internal_page?: { _ref: string };
|
|
148
|
+
external_url?: string;
|
|
149
|
+
content_type?: string;
|
|
150
|
+
content_asset?: string;
|
|
151
|
+
content_url?: string;
|
|
152
|
+
visible?: boolean;
|
|
153
|
+
grid_column?: number;
|
|
154
|
+
column_span?: number;
|
|
155
|
+
style_overrides?: Record<string, unknown>;
|
|
156
|
+
}) => ({
|
|
157
|
+
_key: item._key || crypto.randomUUID().slice(0, 8),
|
|
158
|
+
_type: "object",
|
|
159
|
+
type: item.type || "menu-item",
|
|
160
|
+
label: item.label,
|
|
161
|
+
...(item.logo_image ? { logo_image: item.logo_image } : {}),
|
|
162
|
+
link_type: item.link_type,
|
|
163
|
+
...(item.link_type === "internal" && item.internal_page
|
|
164
|
+
? { internal_page: { _type: "reference", _ref: item.internal_page._ref } }
|
|
165
|
+
: {}),
|
|
166
|
+
...(item.link_type === "external" && item.external_url
|
|
167
|
+
? { external_url: item.external_url }
|
|
168
|
+
: {}),
|
|
169
|
+
...(item.link_type === "content" ? {
|
|
170
|
+
...(item.content_type ? { content_type: item.content_type } : {}),
|
|
171
|
+
...(item.content_asset ? { content_asset: item.content_asset } : {}),
|
|
172
|
+
...(item.content_url ? { content_url: item.content_url } : {}),
|
|
173
|
+
} : {}),
|
|
174
|
+
visible: item.visible !== false,
|
|
175
|
+
...(typeof item.grid_column === "number" &&
|
|
176
|
+
item.grid_column >= 1 &&
|
|
177
|
+
item.grid_column <= 12
|
|
178
|
+
? { grid_column: item.grid_column }
|
|
179
|
+
: {}),
|
|
180
|
+
column_span: typeof item.column_span === "number" && item.column_span >= 1
|
|
181
|
+
? Math.min(item.column_span, 12)
|
|
182
|
+
: 1,
|
|
183
|
+
...(item.style_overrides && Object.keys(item.style_overrides).length > 0
|
|
184
|
+
? { style_overrides: item.style_overrides }
|
|
185
|
+
: {}),
|
|
186
|
+
})
|
|
187
|
+
),
|
|
188
|
+
};
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
case "nav_design": {
|
|
193
|
+
const nd = data.nav_design || {};
|
|
194
|
+
patch = {
|
|
195
|
+
nav_design: {
|
|
196
|
+
_type: "object",
|
|
197
|
+
logo_text: typeof nd.logo_text === "string" ? nd.logo_text.slice(0, 200) : cfg.defaults.logoText,
|
|
198
|
+
color: ["yellow-lime", "yellow", "red-coral", "blue", "green", "white"].includes(nd.color) ? nd.color : "yellow-lime",
|
|
199
|
+
position: ["fixed", "sticky", "static"].includes(nd.position) ? nd.position : "fixed",
|
|
200
|
+
hide_on_scroll: nd.hide_on_scroll !== false,
|
|
201
|
+
font_size: typeof nd.font_size === "number" ? Math.max(8, Math.min(48, nd.font_size)) : 14,
|
|
202
|
+
font_family: typeof nd.font_family === "string" ? nd.font_family.slice(0, 200) : "",
|
|
203
|
+
text_align: ["left", "center", "right"].includes(nd.text_align) ? nd.text_align : "left",
|
|
204
|
+
text_transform: ["none", "uppercase", "lowercase", "capitalize"].includes(nd.text_transform) ? nd.text_transform : "uppercase",
|
|
205
|
+
padding_h: typeof nd.padding_h === "number" ? Math.max(0, Math.min(200, nd.padding_h)) : 24,
|
|
206
|
+
padding_v: typeof nd.padding_v === "number" ? Math.max(0, Math.min(200, nd.padding_v)) : 27,
|
|
207
|
+
margin_h: typeof nd.margin_h === "number" ? Math.max(0, Math.min(200, nd.margin_h)) : 0,
|
|
208
|
+
margin_v: typeof nd.margin_v === "number" ? Math.max(0, Math.min(200, nd.margin_v)) : 0,
|
|
209
|
+
background_color: typeof nd.background_color === "string" ? nd.background_color.slice(0, 50) : "",
|
|
210
|
+
background_opacity: typeof nd.background_opacity === "number" ? Math.max(0, Math.min(100, nd.background_opacity)) : 0,
|
|
211
|
+
backdrop_blur: !!nd.backdrop_blur,
|
|
212
|
+
items_gap: typeof nd.items_gap === "number" ? Math.max(0, Math.min(200, nd.items_gap)) : 32,
|
|
213
|
+
logo_columns: typeof nd.logo_columns === "number" ? Math.max(1, Math.min(6, Math.round(nd.logo_columns))) : 3,
|
|
214
|
+
vertical_align: ["top", "middle", "bottom"].includes(nd.vertical_align) ? nd.vertical_align : "top",
|
|
215
|
+
font_weight: typeof nd.font_weight === "string" ? nd.font_weight.slice(0, 10) : "400",
|
|
216
|
+
// ── Entrance animation ──
|
|
217
|
+
entrance_animation: ["", "fade-in", "slide-down", "blur-in"].includes(nd.entrance_animation) ? nd.entrance_animation : "",
|
|
218
|
+
entrance_duration: typeof nd.entrance_duration === "number" ? Math.max(200, Math.min(5000, nd.entrance_duration)) : 600,
|
|
219
|
+
entrance_delay: typeof nd.entrance_delay === "number" ? Math.max(0, Math.min(5000, nd.entrance_delay)) : 0,
|
|
220
|
+
entrance_stagger: !!nd.entrance_stagger,
|
|
221
|
+
entrance_stagger_delay: typeof nd.entrance_stagger_delay === "number" ? Math.max(20, Math.min(500, nd.entrance_stagger_delay)) : 80,
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
case "metadata": {
|
|
228
|
+
// Validate text field lengths
|
|
229
|
+
if (data.default_title && typeof data.default_title === "string" && data.default_title.length > 1000) {
|
|
230
|
+
return NextResponse.json(
|
|
231
|
+
{ error: "Title field exceeds maximum length of 1000 characters" },
|
|
232
|
+
{ status: 400 }
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
if (data.default_description && typeof data.default_description === "string" && data.default_description.length > 1000) {
|
|
236
|
+
return NextResponse.json(
|
|
237
|
+
{ error: "Description field exceeds maximum length of 1000 characters" },
|
|
238
|
+
{ status: 400 }
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
patch = {
|
|
242
|
+
default_title: data.default_title || "",
|
|
243
|
+
default_description: data.default_description || "",
|
|
244
|
+
default_og_image: data.default_og_image || "",
|
|
245
|
+
favicon_path: data.favicon_path || "",
|
|
246
|
+
analytics_id: data.analytics_id || "",
|
|
247
|
+
};
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
case "assets": {
|
|
252
|
+
patch = {};
|
|
253
|
+
if (data.asset_base_url !== undefined) {
|
|
254
|
+
patch.asset_base_url = data.asset_base_url;
|
|
255
|
+
}
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Ensure the document exists before patching
|
|
261
|
+
await writeClient.createIfNotExists({
|
|
262
|
+
_id: SETTINGS_ID,
|
|
263
|
+
_type: "siteSettings",
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Apply the patch
|
|
267
|
+
await writeClient.patch(SETTINGS_ID).set(patch).commit();
|
|
268
|
+
|
|
269
|
+
auditLog("settings.update", { section });
|
|
270
|
+
|
|
271
|
+
return NextResponse.json({ success: true, section });
|
|
272
|
+
} catch (error) {
|
|
273
|
+
logger.error("[Admin:Settings]", "Failed to save settings:", error);
|
|
274
|
+
return NextResponse.json(
|
|
275
|
+
{ error: "Failed to save settings" },
|
|
276
|
+
{ status: 500 }
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { isAdminAuthenticated } from "../../../../../lib/auth";
|
|
3
|
+
import { writeClient } from "../../../../../lib/sanity/writeClient";
|
|
4
|
+
import { client } from "../../../../../lib/sanity/client";
|
|
5
|
+
import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
|
|
6
|
+
import { auditLog } from "../../../../../lib/audit";
|
|
7
|
+
import { logger } from "../../../../../lib/logger";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* POST /api/admin/setup/complete — Mark setup wizard as complete.
|
|
11
|
+
*
|
|
12
|
+
* Sets `setup_complete: true` on the siteSettings document.
|
|
13
|
+
* Called when the user clicks "Start Building" on the final wizard step.
|
|
14
|
+
* This prevents the wizard from re-triggering on subsequent admin visits.
|
|
15
|
+
*/
|
|
16
|
+
export async function POST(request: NextRequest) {
|
|
17
|
+
const authenticated = await isAdminAuthenticated();
|
|
18
|
+
if (!authenticated) {
|
|
19
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!validateCsrf(request)) {
|
|
23
|
+
return csrfErrorResponse();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
// Find the siteSettings document
|
|
28
|
+
const doc = await client.fetch<{ _id: string } | null>(
|
|
29
|
+
`*[_type == "siteSettings"][0]{ _id }`
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
if (!doc) {
|
|
33
|
+
return NextResponse.json(
|
|
34
|
+
{ error: "siteSettings document not found. Run database setup first." },
|
|
35
|
+
{ status: 400 }
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Set the completion flag
|
|
40
|
+
await writeClient.patch(doc._id).set({ setup_complete: true }).commit();
|
|
41
|
+
|
|
42
|
+
auditLog("setup.complete", {});
|
|
43
|
+
logger.info("[Setup]", "Setup wizard marked as complete");
|
|
44
|
+
|
|
45
|
+
return NextResponse.json({ success: true });
|
|
46
|
+
} catch (err) {
|
|
47
|
+
const message = err instanceof Error ? err.message : "Failed to complete setup";
|
|
48
|
+
logger.error("[Setup]", "Failed to mark setup complete:", err);
|
|
49
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { isAdminAuthenticated } from "../../../../lib/auth";
|
|
3
|
+
import { getSetupStatus } from "../../../../lib/setup/detect";
|
|
4
|
+
import { client } from "../../../../lib/sanity/client";
|
|
5
|
+
import { writeClient } from "../../../../lib/sanity/writeClient";
|
|
6
|
+
import { validateCsrf, csrfErrorResponse } from "../../../../lib/csrf";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* GET /api/admin/setup — Returns current setup/onboarding status.
|
|
10
|
+
* Used by the admin layout to decide whether to show the wizard,
|
|
11
|
+
* and by the wizard itself to know which steps are already done.
|
|
12
|
+
*/
|
|
13
|
+
export async function GET() {
|
|
14
|
+
const authenticated = await isAdminAuthenticated();
|
|
15
|
+
if (!authenticated) {
|
|
16
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const status = await getSetupStatus();
|
|
21
|
+
return NextResponse.json(status);
|
|
22
|
+
} catch {
|
|
23
|
+
return NextResponse.json(
|
|
24
|
+
{ error: "Failed to check setup status" },
|
|
25
|
+
{ status: 500 }
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* POST /api/admin/setup — Seed initial Sanity documents.
|
|
32
|
+
* Called by the wizard's Database step after a successful connection test.
|
|
33
|
+
* Creates siteSettings, siteStyles, and assetRegistry if they don't exist.
|
|
34
|
+
*/
|
|
35
|
+
export async function POST(request: NextRequest) {
|
|
36
|
+
const authenticated = await isAdminAuthenticated();
|
|
37
|
+
if (!authenticated) {
|
|
38
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!validateCsrf(request)) {
|
|
42
|
+
return csrfErrorResponse();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
// Check which documents already exist
|
|
47
|
+
const existing = await client.fetch<{
|
|
48
|
+
siteSettings: number;
|
|
49
|
+
siteStyles: number;
|
|
50
|
+
assetRegistry: number;
|
|
51
|
+
}>(`{
|
|
52
|
+
"siteSettings": count(*[_type == "siteSettings"]),
|
|
53
|
+
"siteStyles": count(*[_type == "siteStyles"]),
|
|
54
|
+
"assetRegistry": count(*[_type == "assetRegistry"])
|
|
55
|
+
}`);
|
|
56
|
+
|
|
57
|
+
const seeded: string[] = [];
|
|
58
|
+
const skipped: string[] = [];
|
|
59
|
+
|
|
60
|
+
// Seed siteSettings
|
|
61
|
+
if (existing.siteSettings === 0) {
|
|
62
|
+
await writeClient.create({
|
|
63
|
+
_type: "siteSettings",
|
|
64
|
+
site_title: "",
|
|
65
|
+
site_description: "",
|
|
66
|
+
nav_items: [],
|
|
67
|
+
nav_design: {
|
|
68
|
+
position: "fixed",
|
|
69
|
+
hide_on_scroll: true,
|
|
70
|
+
font_size: 14,
|
|
71
|
+
padding_h: 24,
|
|
72
|
+
padding_v: 27,
|
|
73
|
+
margin_h: 0,
|
|
74
|
+
margin_v: 0,
|
|
75
|
+
background_opacity: 100,
|
|
76
|
+
backdrop_blur: false,
|
|
77
|
+
items_gap: 32,
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
seeded.push("siteSettings");
|
|
81
|
+
} else {
|
|
82
|
+
skipped.push("siteSettings");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Seed siteStyles
|
|
86
|
+
if (existing.siteStyles === 0) {
|
|
87
|
+
await writeClient.create({
|
|
88
|
+
_type: "siteStyles",
|
|
89
|
+
grid_columns: 12,
|
|
90
|
+
grid_width: 1200,
|
|
91
|
+
grid_gutter: 24,
|
|
92
|
+
fonts: [],
|
|
93
|
+
color_palette: [],
|
|
94
|
+
typography: {},
|
|
95
|
+
});
|
|
96
|
+
seeded.push("siteStyles");
|
|
97
|
+
} else {
|
|
98
|
+
skipped.push("siteStyles");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Seed assetRegistry
|
|
102
|
+
if (existing.assetRegistry === 0) {
|
|
103
|
+
await writeClient.create({
|
|
104
|
+
_type: "assetRegistry",
|
|
105
|
+
assets: [],
|
|
106
|
+
storage_provider: "",
|
|
107
|
+
});
|
|
108
|
+
seeded.push("assetRegistry");
|
|
109
|
+
} else {
|
|
110
|
+
skipped.push("assetRegistry");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return NextResponse.json({ seeded, skipped });
|
|
114
|
+
} catch (err) {
|
|
115
|
+
const message = err instanceof Error ? err.message : "Seeding failed";
|
|
116
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { revalidatePath } from "next/cache";
|
|
3
|
+
import { isAdminAuthenticated } from "../../../../../lib/auth";
|
|
4
|
+
import { writeClient } from "../../../../../lib/sanity/writeClient";
|
|
5
|
+
import { client } from "../../../../../lib/sanity/client";
|
|
6
|
+
import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
|
|
7
|
+
import { isBodyTooLarge, MAX_JSON_BODY_SIZE, jsonError } from "../../../../../lib/security";
|
|
8
|
+
import { invalidateProviderConfigCache } from "../../../../../lib/storage";
|
|
9
|
+
import { auditLog } from "../../../../../lib/audit";
|
|
10
|
+
import type { StorageProvider } from "../../../../../lib/storage/types";
|
|
11
|
+
import { logger } from "../../../../../lib/logger";
|
|
12
|
+
|
|
13
|
+
const VALID_PROVIDERS: StorageProvider[] = ["r2"];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* POST /api/admin/storage/switch — Switch the active storage provider.
|
|
17
|
+
*
|
|
18
|
+
* Body: { provider: "r2" }
|
|
19
|
+
*
|
|
20
|
+
* 1. Validates the target provider is connected (has credentials).
|
|
21
|
+
* 2. Updates `assetRegistry.storage_provider` in Sanity.
|
|
22
|
+
* 3. Invalidates the in-memory provider config cache.
|
|
23
|
+
* 4. Triggers ISR revalidation of the entire site layout so all
|
|
24
|
+
* server-rendered pages pick up the new asset URLs.
|
|
25
|
+
*
|
|
26
|
+
* The switch assumes providers have the same files at the same
|
|
27
|
+
* relative paths. No automatic data migration occurs.
|
|
28
|
+
*
|
|
29
|
+
* Currently only R2 is supported. The route is preserved for the
|
|
30
|
+
* multi-provider architecture — new providers can be added here.
|
|
31
|
+
*/
|
|
32
|
+
export async function POST(request: NextRequest) {
|
|
33
|
+
if (!(await isAdminAuthenticated())) {
|
|
34
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
35
|
+
}
|
|
36
|
+
if (!validateCsrf(request)) {
|
|
37
|
+
return csrfErrorResponse();
|
|
38
|
+
}
|
|
39
|
+
if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
|
|
40
|
+
return jsonError("Request body too large", 413);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const body = await request.json();
|
|
45
|
+
const { provider } = body;
|
|
46
|
+
|
|
47
|
+
// ── Validate provider value ──
|
|
48
|
+
if (!provider || !VALID_PROVIDERS.includes(provider)) {
|
|
49
|
+
return jsonError(
|
|
50
|
+
`Invalid provider. Must be one of: ${VALID_PROVIDERS.join(", ")}`,
|
|
51
|
+
400
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Check current provider (avoid no-op) ──
|
|
56
|
+
const registry = await client.fetch(
|
|
57
|
+
`*[_type == "assetRegistry"][0]{
|
|
58
|
+
storage_provider,
|
|
59
|
+
r2_bucket_url,
|
|
60
|
+
r2_access_key_id,
|
|
61
|
+
r2_endpoint,
|
|
62
|
+
r2_bucket_name,
|
|
63
|
+
r2_connected_at
|
|
64
|
+
}`
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const currentProvider = registry?.storage_provider || "r2";
|
|
68
|
+
if (currentProvider === provider) {
|
|
69
|
+
return NextResponse.json({
|
|
70
|
+
success: true,
|
|
71
|
+
provider,
|
|
72
|
+
message: `Already using ${provider}`,
|
|
73
|
+
revalidated: false,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Validate target provider is connected ──
|
|
78
|
+
if (provider === "r2") {
|
|
79
|
+
if (!registry?.r2_access_key_id || !registry?.r2_bucket_url || !registry?.r2_endpoint || !registry?.r2_bucket_name) {
|
|
80
|
+
return jsonError(
|
|
81
|
+
"Cannot switch to R2: not connected. Please connect R2 first via the Storage page.",
|
|
82
|
+
400
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Update active provider in Sanity ──
|
|
88
|
+
await writeClient
|
|
89
|
+
.patch("assetRegistry")
|
|
90
|
+
.set({ storage_provider: provider })
|
|
91
|
+
.commit();
|
|
92
|
+
|
|
93
|
+
// ── Invalidate server-side provider cache ──
|
|
94
|
+
invalidateProviderConfigCache();
|
|
95
|
+
|
|
96
|
+
// ── Revalidate the entire site so pages pick up new asset URLs ──
|
|
97
|
+
revalidatePath("/", "layout");
|
|
98
|
+
|
|
99
|
+
auditLog("storage.switch", {
|
|
100
|
+
from: currentProvider,
|
|
101
|
+
to: provider,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return NextResponse.json({
|
|
105
|
+
success: true,
|
|
106
|
+
provider,
|
|
107
|
+
previousProvider: currentProvider,
|
|
108
|
+
revalidated: true,
|
|
109
|
+
});
|
|
110
|
+
} catch (err) {
|
|
111
|
+
logger.error("[Admin:Storage]", "Failed to switch storage provider:", err);
|
|
112
|
+
return NextResponse.json(
|
|
113
|
+
{ error: "Failed to switch storage provider" },
|
|
114
|
+
{ status: 500 }
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { isAdminAuthenticated } from "../../../../../lib/auth";
|
|
3
|
+
import { writeClient } from "../../../../../lib/sanity/writeClient";
|
|
4
|
+
import { validateFontMagicBytes } from "../../../../../lib/security";
|
|
5
|
+
import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
|
|
6
|
+
import { logger } from "../../../../../lib/logger";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* POST /api/admin/styles/fonts — Upload a font file to Sanity CDN
|
|
10
|
+
*
|
|
11
|
+
* Accepts multipart/form-data with a single font file (.woff2, .woff, .ttf, .otf).
|
|
12
|
+
* Validates both file extension AND magic bytes to prevent malicious uploads.
|
|
13
|
+
* Returns the Sanity CDN URL and asset ID for storing in the siteStyles document.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const ALLOWED_EXTENSIONS = [".woff2", ".woff", ".ttf", ".otf"];
|
|
17
|
+
const MIME_MAP: Record<string, string> = {
|
|
18
|
+
".woff2": "font/woff2",
|
|
19
|
+
".woff": "font/woff",
|
|
20
|
+
".ttf": "font/ttf",
|
|
21
|
+
".otf": "font/otf",
|
|
22
|
+
};
|
|
23
|
+
const MAX_FONT_SIZE = 5 * 1024 * 1024; // 5MB
|
|
24
|
+
|
|
25
|
+
export async function POST(request: NextRequest) {
|
|
26
|
+
const authenticated = await isAdminAuthenticated();
|
|
27
|
+
if (!authenticated) {
|
|
28
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
29
|
+
}
|
|
30
|
+
if (!validateCsrf(request)) {
|
|
31
|
+
return csrfErrorResponse();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const formData = await request.formData();
|
|
36
|
+
const file = formData.get("font") as File | null;
|
|
37
|
+
|
|
38
|
+
if (!file) {
|
|
39
|
+
return NextResponse.json(
|
|
40
|
+
{ error: "No font file provided" },
|
|
41
|
+
{ status: 400 }
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Validate extension
|
|
46
|
+
const filename = file.name.toLowerCase();
|
|
47
|
+
const ext = filename.substring(filename.lastIndexOf("."));
|
|
48
|
+
if (!ALLOWED_EXTENSIONS.includes(ext)) {
|
|
49
|
+
return NextResponse.json(
|
|
50
|
+
{ error: "Invalid file type. Allowed: .woff2, .woff, .ttf, .otf" },
|
|
51
|
+
{ status: 400 }
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Validate file size (max 5MB)
|
|
56
|
+
if (file.size > MAX_FONT_SIZE) {
|
|
57
|
+
return NextResponse.json(
|
|
58
|
+
{ error: "Font file too large. Maximum 5MB." },
|
|
59
|
+
{ status: 400 }
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Read file content
|
|
64
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
65
|
+
|
|
66
|
+
// Validate magic bytes — ensure file content matches the claimed extension
|
|
67
|
+
if (!validateFontMagicBytes(arrayBuffer, ext)) {
|
|
68
|
+
return NextResponse.json(
|
|
69
|
+
{ error: "File content does not match the expected font format" },
|
|
70
|
+
{ status: 400 }
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Convert to Buffer for Sanity upload
|
|
75
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
76
|
+
|
|
77
|
+
// Upload to Sanity CDN with correct Content-Type
|
|
78
|
+
const asset = await writeClient.assets.upload("file", buffer, {
|
|
79
|
+
filename: file.name,
|
|
80
|
+
contentType: MIME_MAP[ext] || "application/octet-stream",
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return NextResponse.json({
|
|
84
|
+
success: true,
|
|
85
|
+
file_url: asset.url,
|
|
86
|
+
file_id: asset._id,
|
|
87
|
+
original_filename: file.name,
|
|
88
|
+
size: file.size,
|
|
89
|
+
});
|
|
90
|
+
} catch (error) {
|
|
91
|
+
logger.error("[Admin:Styles]", "Failed to upload font:", error);
|
|
92
|
+
return NextResponse.json(
|
|
93
|
+
{ error: "Failed to upload font file" },
|
|
94
|
+
{ status: 500 }
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|