@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.
Files changed (319) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -0
  3. package/admin/assets.ts +4 -0
  4. package/admin/database.ts +4 -0
  5. package/admin/index.ts +6 -0
  6. package/admin/login.ts +4 -0
  7. package/admin/navigation.ts +4 -0
  8. package/admin/pages-editor.ts +4 -0
  9. package/admin/pages.ts +4 -0
  10. package/admin/projects-editor.ts +4 -0
  11. package/admin/projects.ts +4 -0
  12. package/admin/settings.ts +4 -0
  13. package/admin/setup.ts +4 -0
  14. package/admin/storage.ts +4 -0
  15. package/admin/styles.ts +4 -0
  16. package/app/(site)/[slug]/loading.tsx +20 -0
  17. package/app/(site)/[slug]/page.tsx +83 -0
  18. package/app/(site)/error.tsx +32 -0
  19. package/app/(site)/layout.tsx +53 -0
  20. package/app/(site)/loading.tsx +20 -0
  21. package/app/(site)/not-found.tsx +41 -0
  22. package/app/(site)/page.tsx +43 -0
  23. package/app/(site)/preview/page.tsx +99 -0
  24. package/app/(site)/work/[slug]/loading.tsx +23 -0
  25. package/app/(site)/work/[slug]/page.tsx +84 -0
  26. package/app/admin/assets/page.tsx +573 -0
  27. package/app/admin/database/page.tsx +302 -0
  28. package/app/admin/error.tsx +53 -0
  29. package/app/admin/layout.tsx +273 -0
  30. package/app/admin/login/page.tsx +88 -0
  31. package/app/admin/navigation/page.tsx +157 -0
  32. package/app/admin/page.tsx +17 -0
  33. package/app/admin/pages/[slug]/page.tsx +849 -0
  34. package/app/admin/pages/page.tsx +588 -0
  35. package/app/admin/projects/[slug]/page.tsx +3 -0
  36. package/app/admin/projects/page.tsx +669 -0
  37. package/app/admin/settings/page.tsx +132 -0
  38. package/app/admin/setup/page.tsx +64 -0
  39. package/app/admin/storage/page.tsx +518 -0
  40. package/app/admin/styles/page.tsx +243 -0
  41. package/app/api/admin/assets/file/route.ts +81 -0
  42. package/app/api/admin/assets/health/route.ts +170 -0
  43. package/app/api/admin/assets/register/route.ts +163 -0
  44. package/app/api/admin/assets/registry/route.ts +98 -0
  45. package/app/api/admin/assets/relink/confirm/route.ts +242 -0
  46. package/app/api/admin/assets/relink/route.ts +202 -0
  47. package/app/api/admin/assets/scan/route.ts +271 -0
  48. package/app/api/admin/auth/route.ts +160 -0
  49. package/app/api/admin/custom-sections/[slug]/route.ts +159 -0
  50. package/app/api/admin/custom-sections/route.ts +127 -0
  51. package/app/api/admin/database/route.ts +53 -0
  52. package/app/api/admin/pages/[slug]/duplicate/route.ts +91 -0
  53. package/app/api/admin/pages/[slug]/route.ts +617 -0
  54. package/app/api/admin/pages/[slug]/set-home/route.ts +76 -0
  55. package/app/api/admin/pages/route.ts +129 -0
  56. package/app/api/admin/preview/route.ts +53 -0
  57. package/app/api/admin/r2/connect/route.ts +181 -0
  58. package/app/api/admin/r2/delete/route.ts +198 -0
  59. package/app/api/admin/r2/disconnect/route.ts +42 -0
  60. package/app/api/admin/r2/rename/route.ts +265 -0
  61. package/app/api/admin/r2/status/route.ts +106 -0
  62. package/app/api/admin/r2/upload-url/route.ts +148 -0
  63. package/app/api/admin/revalidate/route.ts +55 -0
  64. package/app/api/admin/settings/route.ts +279 -0
  65. package/app/api/admin/setup/complete/route.ts +51 -0
  66. package/app/api/admin/setup/route.ts +118 -0
  67. package/app/api/admin/storage/switch/route.ts +117 -0
  68. package/app/api/admin/styles/fonts/route.ts +97 -0
  69. package/app/api/admin/styles/route.ts +304 -0
  70. package/app/api/assets/[...path]/route.ts +98 -0
  71. package/app/api/custom-sections/[id]/route.ts +43 -0
  72. package/app/api/draft-mode/disable/route.ts +10 -0
  73. package/app/api/draft-mode/enable/route.ts +26 -0
  74. package/app/api/projects/route.ts +42 -0
  75. package/app/api/styles/route.ts +88 -0
  76. package/app/favicon.ico +0 -0
  77. package/app/globals.css +7 -0
  78. package/app/layout.tsx +53 -0
  79. package/app/robots.ts +17 -0
  80. package/app/sitemap.ts +48 -0
  81. package/app/studio/[[...index]]/page.tsx +8 -0
  82. package/components/admin/MetadataEditor.tsx +173 -0
  83. package/components/admin/PublishToggle.tsx +130 -0
  84. package/components/admin/icons.tsx +40 -0
  85. package/components/admin/nav-builder/NavBuilder.tsx +182 -0
  86. package/components/admin/nav-builder/NavBuilderGrid.tsx +326 -0
  87. package/components/admin/nav-builder/NavGeneralSettings.tsx +275 -0
  88. package/components/admin/nav-builder/NavGridCell.tsx +48 -0
  89. package/components/admin/nav-builder/NavGridItem.tsx +189 -0
  90. package/components/admin/nav-builder/NavItemSettings.tsx +288 -0
  91. package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -0
  92. package/components/admin/nav-builder/NavLivePreview.tsx +125 -0
  93. package/components/admin/nav-builder/NavSettingsFields.tsx +248 -0
  94. package/components/admin/nav-builder/NavSettingsPanel.tsx +127 -0
  95. package/components/admin/nav-builder/index.ts +10 -0
  96. package/components/admin/nav-builder/nav-builder-utils.ts +238 -0
  97. package/components/admin/setup-wizard/BrandingStep.tsx +218 -0
  98. package/components/admin/setup-wizard/DatabaseStep.tsx +331 -0
  99. package/components/admin/setup-wizard/DoneStep.tsx +187 -0
  100. package/components/admin/setup-wizard/SetupWizard.tsx +166 -0
  101. package/components/admin/setup-wizard/StorageStep.tsx +308 -0
  102. package/components/admin/setup-wizard/WelcomeStep.tsx +96 -0
  103. package/components/admin/setup-wizard/index.ts +9 -0
  104. package/components/admin/styles/ColorsEditor.tsx +214 -0
  105. package/components/admin/styles/FontsEditor.tsx +258 -0
  106. package/components/admin/styles/GridLayoutEditor.tsx +292 -0
  107. package/components/admin/styles/LinksButtonsEditor.tsx +120 -0
  108. package/components/admin/styles/TypographyEditor.tsx +266 -0
  109. package/components/admin/styles/index.ts +9 -0
  110. package/components/admin/styles/shared.tsx +68 -0
  111. package/components/blocks/BlockRenderer.tsx +404 -0
  112. package/components/blocks/ButtonBlockRenderer.tsx +52 -0
  113. package/components/blocks/CoverBlockRenderer.tsx +239 -0
  114. package/components/blocks/CustomSectionInstanceRenderer.tsx +82 -0
  115. package/components/blocks/EnterAnimationWrapper.tsx +140 -0
  116. package/components/blocks/HoverAnimationWrapper.tsx +308 -0
  117. package/components/blocks/ImageBlockRenderer.tsx +61 -0
  118. package/components/blocks/ImageGridBlockRenderer.tsx +545 -0
  119. package/components/blocks/PageBackground.tsx +28 -0
  120. package/components/blocks/PageNavAnimation.tsx +35 -0
  121. package/components/blocks/PageNavColor.tsx +24 -0
  122. package/components/blocks/PageRenderer.tsx +142 -0
  123. package/components/blocks/ParallaxGroupRenderer.tsx +448 -0
  124. package/components/blocks/ParallaxSlideRenderer.tsx +175 -0
  125. package/components/blocks/ProjectGridBlockRenderer.tsx +556 -0
  126. package/components/blocks/SectionRenderer.tsx +170 -0
  127. package/components/blocks/SectionV2Renderer.tsx +330 -0
  128. package/components/blocks/ShaderCanvas.tsx +392 -0
  129. package/components/blocks/SpacerBlockRenderer.tsx +17 -0
  130. package/components/blocks/TextBlockRenderer.tsx +87 -0
  131. package/components/blocks/TypewriterRichText.tsx +464 -0
  132. package/components/blocks/TypewriterWrapper.tsx +149 -0
  133. package/components/blocks/VideoBlockRenderer.tsx +304 -0
  134. package/components/blocks/index.ts +2 -0
  135. package/components/builder/AssetBrowser.tsx +2 -0
  136. package/components/builder/BlockLivePreview.tsx +101 -0
  137. package/components/builder/BlockTypePicker.tsx +178 -0
  138. package/components/builder/BuilderCanvas.tsx +354 -0
  139. package/components/builder/CanvasMinimap.tsx +200 -0
  140. package/components/builder/CanvasToolbar.tsx +202 -0
  141. package/components/builder/ColorPicker.tsx +243 -0
  142. package/components/builder/ColorSwatchPicker.tsx +274 -0
  143. package/components/builder/ColumnDragContext.tsx +51 -0
  144. package/components/builder/ColumnDragOverlay.tsx +110 -0
  145. package/components/builder/CustomSectionInstanceCard.tsx +97 -0
  146. package/components/builder/DeviceFrame.tsx +123 -0
  147. package/components/builder/DndWrapper.tsx +337 -0
  148. package/components/builder/InsertionLines.tsx +186 -0
  149. package/components/builder/ParallaxGroupCanvas.tsx +228 -0
  150. package/components/builder/ParallaxSlideHeader.tsx +113 -0
  151. package/components/builder/ReadOnlyFrame.tsx +417 -0
  152. package/components/builder/SectionEditorBar.tsx +288 -0
  153. package/components/builder/SectionTypePicker.tsx +422 -0
  154. package/components/builder/SectionV2Canvas.tsx +297 -0
  155. package/components/builder/SectionV2Column.tsx +488 -0
  156. package/components/builder/SettingsPanel.tsx +911 -0
  157. package/components/builder/SortableBlock.tsx +230 -0
  158. package/components/builder/SortableRow.tsx +362 -0
  159. package/components/builder/VirtualAssetGrid.tsx +397 -0
  160. package/components/builder/asset-browser/AssetBrowser.tsx +178 -0
  161. package/components/builder/asset-browser/FileLightbox.tsx +116 -0
  162. package/components/builder/asset-browser/FolderTreeItem.tsx +55 -0
  163. package/components/builder/asset-browser/R2BrowserContent.tsx +436 -0
  164. package/components/builder/asset-browser/R2ContextMenu.tsx +98 -0
  165. package/components/builder/asset-browser/VideoThumbnail.tsx +63 -0
  166. package/components/builder/asset-browser/helpers.ts +88 -0
  167. package/components/builder/asset-browser/index.ts +1 -0
  168. package/components/builder/asset-browser/types.ts +49 -0
  169. package/components/builder/asset-browser/useAssetBrowser.ts +344 -0
  170. package/components/builder/asset-browser/useR2DragDrop.ts +116 -0
  171. package/components/builder/asset-browser/useR2Operations.ts +189 -0
  172. package/components/builder/blockStyles.tsx +295 -0
  173. package/components/builder/editors/ButtonBlockEditor.tsx +184 -0
  174. package/components/builder/editors/CoverBlockEditor.tsx +488 -0
  175. package/components/builder/editors/EnterAnimationPicker.tsx +297 -0
  176. package/components/builder/editors/HoverEffectPicker.tsx +209 -0
  177. package/components/builder/editors/ImageBlockEditor.tsx +206 -0
  178. package/components/builder/editors/ImageGridBlockEditor.tsx +386 -0
  179. package/components/builder/editors/ProjectGridEditor.tsx +648 -0
  180. package/components/builder/editors/SpacerBlockEditor.tsx +167 -0
  181. package/components/builder/editors/StaggerSettings.tsx +108 -0
  182. package/components/builder/editors/TextAlignmentIcons.tsx +39 -0
  183. package/components/builder/editors/TextBlockEditor.tsx +462 -0
  184. package/components/builder/editors/TextStylePicker.tsx +183 -0
  185. package/components/builder/editors/VideoBlockEditor.tsx +278 -0
  186. package/components/builder/editors/index.ts +10 -0
  187. package/components/builder/editors/shared.tsx +345 -0
  188. package/components/builder/hooks/useColumnDrag.ts +472 -0
  189. package/components/builder/hooks/useColumnResize.ts +221 -0
  190. package/components/builder/index.ts +12 -0
  191. package/components/builder/live-preview/LiveButtonPreview.tsx +38 -0
  192. package/components/builder/live-preview/LiveCoverPreview.tsx +146 -0
  193. package/components/builder/live-preview/LiveImageGridPreview.tsx +123 -0
  194. package/components/builder/live-preview/LiveImagePreview.tsx +107 -0
  195. package/components/builder/live-preview/LiveProjectGridPreview.tsx +1010 -0
  196. package/components/builder/live-preview/LiveSpacerPreview.tsx +9 -0
  197. package/components/builder/live-preview/LiveTextEditor.tsx +198 -0
  198. package/components/builder/live-preview/LiveVideoPreview.tsx +98 -0
  199. package/components/builder/live-preview/index.ts +10 -0
  200. package/components/builder/live-preview/shared.tsx +153 -0
  201. package/components/builder/settings-panel/BlockLayoutTab.tsx +532 -0
  202. package/components/builder/settings-panel/BlockSettings.tsx +94 -0
  203. package/components/builder/settings-panel/ColumnV2Settings.tsx +160 -0
  204. package/components/builder/settings-panel/LayoutTab.tsx +310 -0
  205. package/components/builder/settings-panel/PageSettings.tsx +200 -0
  206. package/components/builder/settings-panel/ParallaxGroupSettings.tsx +118 -0
  207. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +178 -0
  208. package/components/builder/settings-panel/SectionV2AnimationTab.tsx +103 -0
  209. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +312 -0
  210. package/components/builder/settings-panel/SectionV2Settings.tsx +323 -0
  211. package/components/builder/settings-panel/TRBLInputs.tsx +51 -0
  212. package/components/builder/settings-panel/index.ts +19 -0
  213. package/components/builder/settings-panel/responsive-helpers.ts +524 -0
  214. package/components/ui/CustomCursor.tsx +118 -0
  215. package/components/ui/NavContentLightbox.tsx +152 -0
  216. package/components/ui/Navbar.tsx +582 -0
  217. package/components/ui/PortfolioTracker.tsx +87 -0
  218. package/components/ui/ScrollToTop.tsx +47 -0
  219. package/lib/animation/enter-presets.ts +147 -0
  220. package/lib/animation/enter-resolve.ts +90 -0
  221. package/lib/animation/enter-types.ts +128 -0
  222. package/lib/animation/hover-effect-presets.ts +210 -0
  223. package/lib/animation/hover-effect-types.ts +126 -0
  224. package/lib/asset-retry.ts +111 -0
  225. package/lib/assets.ts +92 -0
  226. package/lib/audit.ts +35 -0
  227. package/lib/auth-token.ts +94 -0
  228. package/lib/auth.ts +13 -0
  229. package/lib/builder/cascade-helpers.ts +51 -0
  230. package/lib/builder/cascade.ts +533 -0
  231. package/lib/builder/constants.ts +103 -0
  232. package/lib/builder/defaults.ts +182 -0
  233. package/lib/builder/history.ts +48 -0
  234. package/lib/builder/index.ts +21 -0
  235. package/lib/builder/layout-styles.ts +344 -0
  236. package/lib/builder/masonry.ts +166 -0
  237. package/lib/builder/responsive.ts +156 -0
  238. package/lib/builder/serializer.ts +845 -0
  239. package/lib/builder/store-blocks.ts +193 -0
  240. package/lib/builder/store-canvas.ts +319 -0
  241. package/lib/builder/store-helpers.ts +490 -0
  242. package/lib/builder/store-sections.ts +709 -0
  243. package/lib/builder/store.ts +333 -0
  244. package/lib/builder/templates.ts +297 -0
  245. package/lib/builder/types.ts +374 -0
  246. package/lib/builder/utils.ts +37 -0
  247. package/lib/color-utils.ts +116 -0
  248. package/lib/config/index.ts +57 -0
  249. package/lib/config/types.ts +122 -0
  250. package/lib/contexts/AssetContext.tsx +79 -0
  251. package/lib/contexts/NavAnimationContext.tsx +44 -0
  252. package/lib/contexts/NavColorContext.tsx +38 -0
  253. package/lib/contexts/PageExitContext.tsx +194 -0
  254. package/lib/contexts/ThumbStatusContext.tsx +83 -0
  255. package/lib/csrf-client.ts +34 -0
  256. package/lib/csrf.ts +68 -0
  257. package/lib/format-utils.ts +24 -0
  258. package/lib/hooks/useViewport.ts +42 -0
  259. package/lib/logger.ts +81 -0
  260. package/lib/revalidate.ts +23 -0
  261. package/lib/sanitize.ts +91 -0
  262. package/lib/sanity/client.ts +8 -0
  263. package/lib/sanity/queries.ts +486 -0
  264. package/lib/sanity/types.ts +869 -0
  265. package/lib/sanity/writeClient.ts +24 -0
  266. package/lib/security.ts +402 -0
  267. package/lib/setup/detect.ts +156 -0
  268. package/lib/shader/glsl/index.ts +27 -0
  269. package/lib/shader/glsl/pixelate.ts +51 -0
  270. package/lib/shader/glsl/rgb-shift.ts +45 -0
  271. package/lib/shader/glsl/ripple.ts +46 -0
  272. package/lib/shader/glsl/vertex.ts +14 -0
  273. package/lib/storage/index.ts +211 -0
  274. package/lib/storage/r2-adapter.ts +286 -0
  275. package/lib/storage/types.ts +125 -0
  276. package/lib/styles/provider.tsx +267 -0
  277. package/lib/thumbnails/generate.ts +151 -0
  278. package/lib/utils.ts +6 -0
  279. package/package.json +212 -0
  280. package/sanity/compose.ts +65 -0
  281. package/sanity/sanity.config.ts +126 -0
  282. package/sanity/schemas/assetRegistry.ts +301 -0
  283. package/sanity/schemas/blocks/blockLayout.ts +90 -0
  284. package/sanity/schemas/blocks/buttonBlock.ts +82 -0
  285. package/sanity/schemas/blocks/coverBlock.ts +229 -0
  286. package/sanity/schemas/blocks/imageBlock.ts +58 -0
  287. package/sanity/schemas/blocks/imageGridBlock.ts +112 -0
  288. package/sanity/schemas/blocks/index.ts +9 -0
  289. package/sanity/schemas/blocks/projectGridBlock.ts +251 -0
  290. package/sanity/schemas/blocks/spacerBlock.ts +41 -0
  291. package/sanity/schemas/blocks/textBlock.ts +139 -0
  292. package/sanity/schemas/blocks/videoBlock.ts +80 -0
  293. package/sanity/schemas/customSection.ts +69 -0
  294. package/sanity/schemas/customSectionInstance.ts +163 -0
  295. package/sanity/schemas/index.ts +111 -0
  296. package/sanity/schemas/objects/enterAnimationConfig.ts +72 -0
  297. package/sanity/schemas/objects/hoverEffectConfig.ts +90 -0
  298. package/sanity/schemas/objects/parallaxGroup.ts +66 -0
  299. package/sanity/schemas/objects/parallaxSlide.ts +217 -0
  300. package/sanity/schemas/objects/typewriterConfig.ts +38 -0
  301. package/sanity/schemas/page.ts +162 -0
  302. package/sanity/schemas/pageSection.ts +157 -0
  303. package/sanity/schemas/pageSectionV2.ts +269 -0
  304. package/sanity/schemas/siteSettings.ts +256 -0
  305. package/sanity/schemas/siteStyles.ts +212 -0
  306. package/site/error.ts +4 -0
  307. package/site/index.ts +8 -0
  308. package/site/not-found.ts +4 -0
  309. package/site/page.ts +4 -0
  310. package/site/preview.ts +4 -0
  311. package/site/robots.ts +4 -0
  312. package/site/sitemap.ts +4 -0
  313. package/site/work.ts +4 -0
  314. package/studio/index.ts +4 -0
  315. package/styles/admin.css +85 -0
  316. package/styles/animations.css +237 -0
  317. package/styles/base.css +148 -0
  318. package/styles/globals.css +10 -0
  319. 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
+ }