@morphika/webframe 0.1.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.
- package/LICENSE +21 -0
- package/README.md +46 -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 +210 -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,265 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { S3Client, CopyObjectCommand, DeleteObjectCommand, ListObjectsV2Command, HeadObjectCommand } 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/rename — Rename/move a file or folder in R2.
|
|
15
|
+
*
|
|
16
|
+
* R2 (S3) doesn't have a rename operation — it's copy + delete.
|
|
17
|
+
*
|
|
18
|
+
* Body: { oldKey: string, newKey: string, isFolder?: boolean }
|
|
19
|
+
* - For files: copies object to new key, deletes old one
|
|
20
|
+
* - For folders: copies all objects under prefix, deletes originals
|
|
21
|
+
*
|
|
22
|
+
* Also updates matching entries in the Sanity asset registry.
|
|
23
|
+
* For raster images, also renames the corresponding _thumbs/ thumbnail (best-effort).
|
|
24
|
+
*/
|
|
25
|
+
export async function POST(request: NextRequest) {
|
|
26
|
+
if (!(await isAdminAuthenticated())) {
|
|
27
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
28
|
+
}
|
|
29
|
+
if (!validateCsrf(request)) {
|
|
30
|
+
return csrfErrorResponse();
|
|
31
|
+
}
|
|
32
|
+
if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
|
|
33
|
+
return jsonError("Request body too large", 413);
|
|
34
|
+
}
|
|
35
|
+
// #32: Rate limit R2 rename operations (30 per minute)
|
|
36
|
+
const ip = request.headers.get("x-forwarded-for") || "unknown";
|
|
37
|
+
if (!checkRateLimit(`r2-rename:${ip}`, 30, 60_000)) {
|
|
38
|
+
return jsonError("Too many requests. Please slow down.", 429);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const body = await request.json();
|
|
43
|
+
const { oldKey, newKey, isFolder } = body;
|
|
44
|
+
|
|
45
|
+
if (!oldKey || typeof oldKey !== "string") {
|
|
46
|
+
return jsonError("Old key is required", 400);
|
|
47
|
+
}
|
|
48
|
+
if (!newKey || typeof newKey !== "string") {
|
|
49
|
+
return jsonError("New key is required", 400);
|
|
50
|
+
}
|
|
51
|
+
if (oldKey === newKey) {
|
|
52
|
+
return jsonError("Old and new keys are the same", 400);
|
|
53
|
+
}
|
|
54
|
+
// #2, #3, #33: Always validate BOTH paths regardless of isFolder flag
|
|
55
|
+
if (!isValidAssetPath(oldKey)) {
|
|
56
|
+
return jsonError("Invalid old path", 400);
|
|
57
|
+
}
|
|
58
|
+
if (!isValidAssetPath(newKey)) {
|
|
59
|
+
return jsonError("Invalid new path", 400);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Fetch R2 credentials
|
|
63
|
+
const registry = await client.fetch(
|
|
64
|
+
`*[_type == "assetRegistry"][0]{
|
|
65
|
+
_id, r2_endpoint, r2_bucket_name, r2_access_key_id, r2_secret_access_key, assets
|
|
66
|
+
}`
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (!registry?.r2_endpoint || !registry?.r2_bucket_name) {
|
|
70
|
+
return jsonError("R2 is not connected", 400);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const accessKeyId = await decryptToken(registry.r2_access_key_id);
|
|
74
|
+
const secretAccessKey = await decryptToken(registry.r2_secret_access_key);
|
|
75
|
+
const bucket = registry.r2_bucket_name;
|
|
76
|
+
|
|
77
|
+
const s3 = new S3Client({
|
|
78
|
+
region: "auto",
|
|
79
|
+
endpoint: registry.r2_endpoint,
|
|
80
|
+
credentials: { accessKeyId, secretAccessKey },
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const renamedPairs: Array<{ oldPath: string; newPath: string }> = [];
|
|
84
|
+
|
|
85
|
+
if (isFolder) {
|
|
86
|
+
// Rename all objects under the old prefix
|
|
87
|
+
const oldPrefix = oldKey.replace(/\/+$/, "") + "/";
|
|
88
|
+
const newPrefix = newKey.replace(/\/+$/, "") + "/";
|
|
89
|
+
let continuationToken: string | undefined;
|
|
90
|
+
|
|
91
|
+
do {
|
|
92
|
+
const list = await s3.send(
|
|
93
|
+
new ListObjectsV2Command({
|
|
94
|
+
Bucket: bucket,
|
|
95
|
+
Prefix: oldPrefix,
|
|
96
|
+
ContinuationToken: continuationToken,
|
|
97
|
+
MaxKeys: 1000,
|
|
98
|
+
})
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
for (const obj of list.Contents || []) {
|
|
102
|
+
const objKey = obj.Key!;
|
|
103
|
+
const newObjKey = newPrefix + objKey.slice(oldPrefix.length);
|
|
104
|
+
|
|
105
|
+
// Copy to new key
|
|
106
|
+
await s3.send(
|
|
107
|
+
new CopyObjectCommand({
|
|
108
|
+
Bucket: bucket,
|
|
109
|
+
CopySource: `${bucket}/${objKey}`,
|
|
110
|
+
Key: newObjKey,
|
|
111
|
+
})
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// Delete old key
|
|
115
|
+
await s3.send(
|
|
116
|
+
new DeleteObjectCommand({ Bucket: bucket, Key: objKey })
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
renamedPairs.push({ oldPath: objKey, newPath: newObjKey });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
continuationToken = list.NextContinuationToken;
|
|
123
|
+
} while (continuationToken);
|
|
124
|
+
|
|
125
|
+
// ── Thumbnail propagation: rename _thumbs/{oldFolder}/ → _thumbs/{newFolder}/ ──
|
|
126
|
+
// Best-effort — failures here don't affect the main operation.
|
|
127
|
+
try {
|
|
128
|
+
const oldThumbPrefix = `_thumbs/${oldPrefix}`;
|
|
129
|
+
const newThumbPrefix = `_thumbs/${newPrefix}`;
|
|
130
|
+
let thumbContinuation: string | undefined;
|
|
131
|
+
|
|
132
|
+
do {
|
|
133
|
+
const thumbList = await s3.send(
|
|
134
|
+
new ListObjectsV2Command({
|
|
135
|
+
Bucket: bucket,
|
|
136
|
+
Prefix: oldThumbPrefix,
|
|
137
|
+
ContinuationToken: thumbContinuation,
|
|
138
|
+
MaxKeys: 1000,
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
for (const obj of thumbList.Contents || []) {
|
|
143
|
+
const thumbKey = obj.Key!;
|
|
144
|
+
const newThumbKey = newThumbPrefix + thumbKey.slice(oldThumbPrefix.length);
|
|
145
|
+
|
|
146
|
+
await s3.send(
|
|
147
|
+
new CopyObjectCommand({
|
|
148
|
+
Bucket: bucket,
|
|
149
|
+
CopySource: `${bucket}/${thumbKey}`,
|
|
150
|
+
Key: newThumbKey,
|
|
151
|
+
})
|
|
152
|
+
);
|
|
153
|
+
await s3.send(
|
|
154
|
+
new DeleteObjectCommand({ Bucket: bucket, Key: thumbKey })
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
thumbContinuation = thumbList.NextContinuationToken;
|
|
159
|
+
} while (thumbContinuation);
|
|
160
|
+
} catch (thumbErr) {
|
|
161
|
+
logger.warn("[Admin:R2]", "Failed to rename folder thumbnails (best-effort)", thumbErr);
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
// #24: Check if target key already exists to prevent silent overwrite
|
|
165
|
+
try {
|
|
166
|
+
await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: newKey }));
|
|
167
|
+
return jsonError("A file already exists at the target path", 409);
|
|
168
|
+
} catch (headErr: unknown) {
|
|
169
|
+
// 404 = target doesn't exist, which is what we want
|
|
170
|
+
const isNotFound = headErr instanceof Error && "name" in headErr && (headErr as { name: string }).name === "NotFound";
|
|
171
|
+
const is404 = headErr instanceof Error && "$metadata" in headErr && (headErr as { $metadata: { httpStatusCode?: number } }).$metadata?.httpStatusCode === 404;
|
|
172
|
+
if (!isNotFound && !is404) {
|
|
173
|
+
// Re-throw unexpected errors
|
|
174
|
+
throw headErr;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// #14: Rename single file: copy first, then delete only after copy succeeds
|
|
179
|
+
await s3.send(
|
|
180
|
+
new CopyObjectCommand({
|
|
181
|
+
Bucket: bucket,
|
|
182
|
+
CopySource: `${bucket}/${oldKey}`,
|
|
183
|
+
Key: newKey,
|
|
184
|
+
})
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
await s3.send(
|
|
188
|
+
new DeleteObjectCommand({ Bucket: bucket, Key: oldKey })
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
renamedPairs.push({ oldPath: oldKey, newPath: newKey });
|
|
192
|
+
|
|
193
|
+
// ── Thumbnail propagation: rename _thumbs/ counterpart for raster images ──
|
|
194
|
+
// Uses direct CopyObject and catches NoSuchKey — avoids extra HeadObject round-trip.
|
|
195
|
+
if (isRasterImage(oldKey)) {
|
|
196
|
+
try {
|
|
197
|
+
const oldThumbKey = thumbKeyForPath(oldKey);
|
|
198
|
+
const newThumbKey = thumbKeyForPath(newKey);
|
|
199
|
+
|
|
200
|
+
await s3.send(
|
|
201
|
+
new CopyObjectCommand({
|
|
202
|
+
Bucket: bucket,
|
|
203
|
+
CopySource: `${bucket}/${oldThumbKey}`,
|
|
204
|
+
Key: newThumbKey,
|
|
205
|
+
})
|
|
206
|
+
);
|
|
207
|
+
await s3.send(
|
|
208
|
+
new DeleteObjectCommand({ Bucket: bucket, Key: oldThumbKey })
|
|
209
|
+
);
|
|
210
|
+
} catch (thumbErr: unknown) {
|
|
211
|
+
// NoSuchKey / 404 = thumbnail didn't exist, skip silently
|
|
212
|
+
const is404 =
|
|
213
|
+
thumbErr instanceof Error &&
|
|
214
|
+
("name" in thumbErr && (thumbErr as { name: string }).name === "NoSuchKey" ||
|
|
215
|
+
"$metadata" in thumbErr && (thumbErr as { $metadata: { httpStatusCode?: number } }).$metadata?.httpStatusCode === 404);
|
|
216
|
+
if (!is404) {
|
|
217
|
+
logger.warn("[Admin:R2]", "Failed to rename thumbnail (best-effort)", thumbErr);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Update Sanity registry — update path, filename, extension for renamed assets
|
|
224
|
+
if (renamedPairs.length > 0 && registry.assets?.length) {
|
|
225
|
+
const pathMap = new Map(renamedPairs.map((p) => [p.oldPath, p.newPath]));
|
|
226
|
+
let patchOps = writeClient.patch(registry._id);
|
|
227
|
+
let hasPatches = false;
|
|
228
|
+
|
|
229
|
+
for (const asset of registry.assets as Array<{ _key: string; path: string }>) {
|
|
230
|
+
const newPath = pathMap.get(asset.path);
|
|
231
|
+
if (newPath) {
|
|
232
|
+
const newFilename = newPath.split("/").pop() || newPath;
|
|
233
|
+
const newExt = newFilename.includes(".")
|
|
234
|
+
? newFilename.split(".").pop()!.toLowerCase()
|
|
235
|
+
: "";
|
|
236
|
+
patchOps = patchOps.set({
|
|
237
|
+
[`assets[_key=="${asset._key}"].path`]: newPath,
|
|
238
|
+
[`assets[_key=="${asset._key}"].filename`]: newFilename,
|
|
239
|
+
[`assets[_key=="${asset._key}"].extension`]: newExt,
|
|
240
|
+
});
|
|
241
|
+
hasPatches = true;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (hasPatches) {
|
|
246
|
+
await patchOps.commit();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
auditLog("r2.rename", { oldKey, newKey, isFolder, count: renamedPairs.length });
|
|
251
|
+
|
|
252
|
+
return NextResponse.json({
|
|
253
|
+
success: true,
|
|
254
|
+
renamedCount: renamedPairs.length,
|
|
255
|
+
});
|
|
256
|
+
} catch (err) {
|
|
257
|
+
// #5: Gracefully handle corrupted encrypted tokens
|
|
258
|
+
if (err instanceof Error && (err.message.includes("decrypt") || err.message.includes("atob") || err.message.includes("Invalid encrypted"))) {
|
|
259
|
+
logger.error("[Admin:R2]", "Failed to decrypt R2 credentials", err);
|
|
260
|
+
return jsonError("R2 credentials are corrupted. Please reconnect R2 in /admin/storage.", 500);
|
|
261
|
+
}
|
|
262
|
+
logger.error("[Admin:R2]", "Failed to rename in R2", err);
|
|
263
|
+
return jsonError("Failed to rename", 500);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";
|
|
3
|
+
import { isAdminAuthenticated } from "../../../../../lib/auth";
|
|
4
|
+
import { client } from "../../../../../lib/sanity/client";
|
|
5
|
+
import { decryptToken } from "../../../../../lib/security";
|
|
6
|
+
import { logger } from "../../../../../lib/logger";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* GET /api/admin/r2/status — Return R2 connection status + storage usage.
|
|
10
|
+
*
|
|
11
|
+
* Returns whether R2 is connected, bucket name, public URL,
|
|
12
|
+
* and total storage used (bytes) by listing all objects in the bucket.
|
|
13
|
+
* Credentials are never returned to the client.
|
|
14
|
+
*/
|
|
15
|
+
export async function GET() {
|
|
16
|
+
if (!(await isAdminAuthenticated())) {
|
|
17
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const registry = await client.fetch(
|
|
22
|
+
`*[_type == "assetRegistry"][0]{
|
|
23
|
+
r2_bucket_url,
|
|
24
|
+
r2_bucket_name,
|
|
25
|
+
r2_endpoint,
|
|
26
|
+
r2_connected_at,
|
|
27
|
+
r2_access_key_id,
|
|
28
|
+
r2_secret_access_key
|
|
29
|
+
}`
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const connected = !!registry?.r2_bucket_url && !!registry?.r2_bucket_name;
|
|
33
|
+
|
|
34
|
+
// Base response
|
|
35
|
+
const response: Record<string, unknown> = {
|
|
36
|
+
connected,
|
|
37
|
+
bucket_name: registry?.r2_bucket_name || null,
|
|
38
|
+
public_url: registry?.r2_bucket_url || null,
|
|
39
|
+
endpoint: registry?.r2_endpoint || null,
|
|
40
|
+
connected_at: registry?.r2_connected_at || null,
|
|
41
|
+
storage_used_bytes: null,
|
|
42
|
+
object_count: null,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// If connected and we have credentials, calculate storage usage
|
|
46
|
+
if (
|
|
47
|
+
connected &&
|
|
48
|
+
registry.r2_endpoint &&
|
|
49
|
+
registry.r2_access_key_id &&
|
|
50
|
+
registry.r2_secret_access_key
|
|
51
|
+
) {
|
|
52
|
+
try {
|
|
53
|
+
const accessKeyId = await decryptToken(registry.r2_access_key_id);
|
|
54
|
+
const secretAccessKey = await decryptToken(registry.r2_secret_access_key);
|
|
55
|
+
|
|
56
|
+
const s3 = new S3Client({
|
|
57
|
+
region: "auto",
|
|
58
|
+
endpoint: registry.r2_endpoint,
|
|
59
|
+
credentials: { accessKeyId, secretAccessKey },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
let totalBytes = 0;
|
|
63
|
+
let totalObjects = 0;
|
|
64
|
+
let continuationToken: string | undefined;
|
|
65
|
+
|
|
66
|
+
// Paginate through all objects to sum sizes
|
|
67
|
+
do {
|
|
68
|
+
const res = await s3.send(
|
|
69
|
+
new ListObjectsV2Command({
|
|
70
|
+
Bucket: registry.r2_bucket_name,
|
|
71
|
+
ContinuationToken: continuationToken,
|
|
72
|
+
MaxKeys: 1000,
|
|
73
|
+
})
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (res.Contents) {
|
|
77
|
+
for (const obj of res.Contents) {
|
|
78
|
+
totalBytes += obj.Size ?? 0;
|
|
79
|
+
totalObjects++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
continuationToken = res.IsTruncated
|
|
84
|
+
? res.NextContinuationToken
|
|
85
|
+
: undefined;
|
|
86
|
+
} while (continuationToken);
|
|
87
|
+
|
|
88
|
+
s3.destroy();
|
|
89
|
+
|
|
90
|
+
response.storage_used_bytes = totalBytes;
|
|
91
|
+
response.object_count = totalObjects;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
logger.warn("[Admin:R2]", "Failed to calculate R2 storage usage", err);
|
|
94
|
+
// Non-fatal — status still returns, just without usage data
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return NextResponse.json(response);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
logger.error("[Admin:R2]", "Failed to check R2 status", err);
|
|
101
|
+
return NextResponse.json(
|
|
102
|
+
{ error: "Failed to check R2 status" },
|
|
103
|
+
{ status: 500 }
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
|
3
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
4
|
+
import { isAdminAuthenticated } from "../../../../../lib/auth";
|
|
5
|
+
import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
|
|
6
|
+
import { isBodyTooLarge, MAX_JSON_BODY_SIZE, jsonError, isValidAssetPath, checkRateLimit } from "../../../../../lib/security";
|
|
7
|
+
import { client } from "../../../../../lib/sanity/client";
|
|
8
|
+
import { decryptToken } from "../../../../../lib/security";
|
|
9
|
+
import { getMimeType, isMediaFile } from "../../../../../lib/storage/types";
|
|
10
|
+
import { logger } from "../../../../../lib/logger";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* POST /api/admin/r2/upload-url — Generate a presigned PUT URL for direct R2 upload.
|
|
14
|
+
*
|
|
15
|
+
* Flow:
|
|
16
|
+
* 1. Client sends { filename, folder, contentType? }.
|
|
17
|
+
* 2. Server validates inputs, generates a presigned PUT URL (5 min TTL).
|
|
18
|
+
* 3. Client uploads directly to R2 using the presigned URL.
|
|
19
|
+
* 4. Client then calls POST /api/admin/assets/register to add the asset to the registry.
|
|
20
|
+
*
|
|
21
|
+
* This keeps all upload bandwidth off Vercel — the browser uploads directly to R2.
|
|
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 upload URL generation (60 per minute)
|
|
34
|
+
const ip = request.headers.get("x-forwarded-for") || "unknown";
|
|
35
|
+
if (!checkRateLimit(`r2-upload:${ip}`, 60, 60_000)) {
|
|
36
|
+
return jsonError("Too many requests. Please slow down.", 429);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const body = await request.json();
|
|
41
|
+
const { filename, folder, contentType } = body;
|
|
42
|
+
|
|
43
|
+
// ── Validate filename ──
|
|
44
|
+
if (!filename || typeof filename !== "string") {
|
|
45
|
+
return jsonError("Filename is required", 400);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Reject filenames with path separators or traversal
|
|
49
|
+
if (filename.includes("/") || filename.includes("\\") || filename.includes("..")) {
|
|
50
|
+
return jsonError("Invalid filename", 400);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Must be a supported media file (except .folder placeholder used for folder creation)
|
|
54
|
+
const isFolderPlaceholder = filename === ".folder";
|
|
55
|
+
if (!isFolderPlaceholder && !isMediaFile(filename)) {
|
|
56
|
+
return jsonError(
|
|
57
|
+
"Unsupported file type. Allowed: jpg, jpeg, png, webp, gif, svg, mp4, webm, mov",
|
|
58
|
+
400
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Validate folder (optional) ──
|
|
63
|
+
// Note: _thumbs/ prefixed folders are valid — used by the auto-thumbnail system
|
|
64
|
+
// to upload generated thumbnails alongside originals.
|
|
65
|
+
const cleanFolder = folder
|
|
66
|
+
? String(folder).replace(/^\/+|\/+$/g, "")
|
|
67
|
+
: "";
|
|
68
|
+
|
|
69
|
+
if (cleanFolder) {
|
|
70
|
+
// Construct full path and validate
|
|
71
|
+
const fullPath = `${cleanFolder}/${filename}`;
|
|
72
|
+
if (!isValidAssetPath(fullPath)) {
|
|
73
|
+
return jsonError("Invalid upload path", 400);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Build the R2 object key ──
|
|
78
|
+
const key = cleanFolder ? `${cleanFolder}/${filename}` : filename;
|
|
79
|
+
|
|
80
|
+
// ── Resolve content type ──
|
|
81
|
+
const resolvedContentType =
|
|
82
|
+
contentType && typeof contentType === "string"
|
|
83
|
+
? contentType
|
|
84
|
+
: getMimeType(filename);
|
|
85
|
+
|
|
86
|
+
// ── Fetch R2 credentials from Sanity ──
|
|
87
|
+
const registry = await client.fetch(
|
|
88
|
+
`*[_type == "assetRegistry"][0]{
|
|
89
|
+
r2_endpoint,
|
|
90
|
+
r2_bucket_name,
|
|
91
|
+
r2_access_key_id,
|
|
92
|
+
r2_secret_access_key,
|
|
93
|
+
r2_bucket_url,
|
|
94
|
+
storage_provider
|
|
95
|
+
}`
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
if (!registry?.r2_endpoint || !registry?.r2_bucket_name) {
|
|
99
|
+
return jsonError("R2 is not connected. Go to /admin/storage to connect your R2 bucket.", 400);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!registry.r2_access_key_id || !registry.r2_secret_access_key) {
|
|
103
|
+
return jsonError("R2 credentials are missing. Reconnect your R2 bucket.", 400);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Decrypt credentials ──
|
|
107
|
+
const accessKeyId = await decryptToken(registry.r2_access_key_id);
|
|
108
|
+
const secretAccessKey = await decryptToken(registry.r2_secret_access_key);
|
|
109
|
+
|
|
110
|
+
// ── Create S3 client and generate presigned URL ──
|
|
111
|
+
const s3 = new S3Client({
|
|
112
|
+
region: "auto",
|
|
113
|
+
endpoint: registry.r2_endpoint,
|
|
114
|
+
credentials: { accessKeyId, secretAccessKey },
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// #26: Include ContentLength condition in presigned URL for server-side enforcement.
|
|
118
|
+
// R2/S3 will reject uploads that don't match this content length.
|
|
119
|
+
const MAX_UPLOAD_SIZE_BYTES = 500 * 1024 * 1024; // 500 MB
|
|
120
|
+
const command = new PutObjectCommand({
|
|
121
|
+
Bucket: registry.r2_bucket_name,
|
|
122
|
+
Key: key,
|
|
123
|
+
ContentType: resolvedContentType,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Presigned URL expires in 5 minutes
|
|
127
|
+
const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 300 });
|
|
128
|
+
|
|
129
|
+
// ── Build the public URL for the uploaded file ──
|
|
130
|
+
const bucketUrl = registry.r2_bucket_url?.replace(/\/$/, "") || "";
|
|
131
|
+
const publicUrl = bucketUrl ? `${bucketUrl}/${key}` : "";
|
|
132
|
+
|
|
133
|
+
return NextResponse.json({
|
|
134
|
+
uploadUrl,
|
|
135
|
+
key,
|
|
136
|
+
publicUrl,
|
|
137
|
+
contentType: resolvedContentType,
|
|
138
|
+
});
|
|
139
|
+
} catch (err) {
|
|
140
|
+
// #5: Gracefully handle corrupted encrypted tokens
|
|
141
|
+
if (err instanceof Error && (err.message.includes("decrypt") || err.message.includes("atob") || err.message.includes("corrupted"))) {
|
|
142
|
+
logger.error("[Admin:R2]", "Failed to decrypt R2 credentials:", err);
|
|
143
|
+
return jsonError("R2 credentials are corrupted. Please reconnect R2 in /admin/storage.", 500);
|
|
144
|
+
}
|
|
145
|
+
logger.error("[Admin:R2]", "Failed to generate presigned upload URL:", err);
|
|
146
|
+
return jsonError("Failed to generate upload URL", 500);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { revalidatePath } from "next/cache";
|
|
3
|
+
import { isAdminAuthenticated } from "../../../../lib/auth";
|
|
4
|
+
import { validateCsrf, csrfErrorResponse } from "../../../../lib/csrf";
|
|
5
|
+
import { logger } from "../../../../lib/logger";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* POST /api/admin/revalidate
|
|
9
|
+
*
|
|
10
|
+
* Purges the ISR cache for specified paths (or all site pages).
|
|
11
|
+
* Called automatically after admin saves (navigation, styles, pages, etc.)
|
|
12
|
+
* so changes appear on the public site immediately without waiting for
|
|
13
|
+
* the 1-hour ISR revalidation window.
|
|
14
|
+
*
|
|
15
|
+
* Body: { paths?: string[] }
|
|
16
|
+
* - If paths provided, revalidates each one
|
|
17
|
+
* - If omitted, revalidates the entire site layout (all pages)
|
|
18
|
+
*/
|
|
19
|
+
export async function POST(request: NextRequest) {
|
|
20
|
+
if (!(await isAdminAuthenticated())) {
|
|
21
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
22
|
+
}
|
|
23
|
+
if (!validateCsrf(request)) {
|
|
24
|
+
return csrfErrorResponse();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const body = await request.json().catch(() => ({}));
|
|
29
|
+
const paths: string[] = Array.isArray(body.paths) ? body.paths : [];
|
|
30
|
+
|
|
31
|
+
if (paths.length > 0) {
|
|
32
|
+
// Revalidate specific paths
|
|
33
|
+
for (const p of paths.slice(0, 50)) {
|
|
34
|
+
if (typeof p === "string" && p.startsWith("/")) {
|
|
35
|
+
revalidatePath(p);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} else {
|
|
39
|
+
// Revalidate the entire site layout — this covers nav, footer, styles
|
|
40
|
+
// that are shared across all pages
|
|
41
|
+
revalidatePath("/", "layout");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return NextResponse.json({
|
|
45
|
+
success: true,
|
|
46
|
+
revalidated: paths.length > 0 ? paths : ["/ (layout)"],
|
|
47
|
+
});
|
|
48
|
+
} catch (err) {
|
|
49
|
+
logger.error("[Admin:Revalidate]", "Revalidation failed:", err);
|
|
50
|
+
return NextResponse.json(
|
|
51
|
+
{ error: "Revalidation failed" },
|
|
52
|
+
{ status: 500 }
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|