@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,24 @@
1
+ import { createClient } from "next-sanity";
2
+
3
+ /**
4
+ * Sanity client with write capabilities.
5
+ * Only used server-side in API routes — never exposed to the browser.
6
+ * Requires SANITY_API_TOKEN environment variable.
7
+ */
8
+
9
+ const token = process.env.SANITY_API_TOKEN;
10
+
11
+ if (!token && process.env.NODE_ENV === "production") {
12
+ console.warn(
13
+ "SANITY_API_TOKEN is not configured. Write operations to Sanity will fail at runtime. " +
14
+ "Set this variable in your hosting dashboard or .env.local file."
15
+ );
16
+ }
17
+
18
+ export const writeClient = createClient({
19
+ projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
20
+ dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || "production",
21
+ apiVersion: "2024-01-01",
22
+ useCdn: false,
23
+ token,
24
+ });
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Shared security utilities for input validation and sanitization.
3
+ * Used across API routes and components.
4
+ */
5
+
6
+ // ─── Request Body Size Limits ─────────────────────────────────────────────
7
+
8
+ /** Default max body size for JSON API routes: 1MB */
9
+ export const MAX_JSON_BODY_SIZE = 1 * 1024 * 1024;
10
+
11
+ /** Max body size for page builder saves (larger due to block content): 5MB */
12
+ export const MAX_PAGE_BODY_SIZE = 5 * 1024 * 1024;
13
+
14
+ /**
15
+ * Check if a request's Content-Length exceeds the specified limit.
16
+ * Returns true if the body is too large.
17
+ * #11: Also rejects requests without Content-Length header (chunked encoding bypass).
18
+ */
19
+ export function isBodyTooLarge(request: Request, maxBytes: number): boolean {
20
+ const contentLength = request.headers.get("content-length");
21
+ if (!contentLength) {
22
+ // #11: No Content-Length means chunked transfer encoding — reject to prevent
23
+ // bypassing size limits. All our JSON mutation endpoints should send Content-Length.
24
+ return true;
25
+ }
26
+ const size = parseInt(contentLength, 10);
27
+ if (isNaN(size) || size > maxBytes) return true;
28
+ return false;
29
+ }
30
+
31
+ /**
32
+ * Read and validate request body with streaming size enforcement.
33
+ * Use this as a safer alternative for endpoints that need to accept
34
+ * bodies without Content-Length headers.
35
+ */
36
+ export async function readBodyWithLimit(request: Request, maxBytes: number): Promise<string> {
37
+ const reader = request.body?.getReader();
38
+ if (!reader) throw new Error("No request body");
39
+
40
+ const chunks: Uint8Array[] = [];
41
+ let totalSize = 0;
42
+
43
+ while (true) {
44
+ const { done, value } = await reader.read();
45
+ if (done) break;
46
+ totalSize += value.byteLength;
47
+ if (totalSize > maxBytes) {
48
+ reader.cancel();
49
+ throw new Error("Request body too large");
50
+ }
51
+ chunks.push(value);
52
+ }
53
+
54
+ const combined = new Uint8Array(totalSize);
55
+ let offset = 0;
56
+ for (const chunk of chunks) {
57
+ combined.set(chunk, offset);
58
+ offset += chunk.byteLength;
59
+ }
60
+ return new TextDecoder().decode(combined);
61
+ }
62
+
63
+ // ─── Standardized Error Responses ──────────────────────────────────────────
64
+
65
+ import { NextResponse } from "next/server";
66
+
67
+ /**
68
+ * Return a standardized JSON error response.
69
+ * All error responses use consistent format: { error: string }
70
+ * with explicit Content-Type header.
71
+ */
72
+ export function jsonError(message: string, status: number): NextResponse {
73
+ return NextResponse.json(
74
+ { error: message },
75
+ {
76
+ status,
77
+ headers: { "Content-Type": "application/json" },
78
+ }
79
+ );
80
+ }
81
+
82
+ // ─── URL Validation ───────────────────────────────────────────────────────
83
+
84
+ /** Allowed URL protocols for user-provided links */
85
+ const SAFE_URL_PROTOCOLS = ["https:", "http:", "mailto:", "tel:"];
86
+
87
+ /**
88
+ * Validate a URL string. Returns true if the URL uses a safe protocol.
89
+ * Rejects javascript:, data:, vbscript:, and other dangerous schemes.
90
+ */
91
+ export function isSafeUrl(url: string): boolean {
92
+ if (!url || typeof url !== "string") return false;
93
+ const trimmed = url.trim();
94
+ if (!trimmed) return false;
95
+
96
+ // Relative URLs and anchors are safe
97
+ if (trimmed.startsWith("/") || trimmed.startsWith("#")) return true;
98
+
99
+ try {
100
+ const parsed = new URL(trimmed);
101
+ return SAFE_URL_PROTOCOLS.includes(parsed.protocol);
102
+ } catch {
103
+ // If URL parsing fails, reject it
104
+ return false;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Validate and sanitize a URL. Returns the URL if safe, or null if dangerous.
110
+ */
111
+ export function sanitizeUrl(url: string): string | null {
112
+ if (!url || typeof url !== "string") return null;
113
+ return isSafeUrl(url) ? url.trim() : null;
114
+ }
115
+
116
+ // ─── CSS Value Sanitization ───────────────────────────────────────────────
117
+
118
+ /** Match valid CSS hex colors: #rgb, #rrggbb, #rrggbbaa */
119
+ const HEX_COLOR_RE = /^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
120
+
121
+ /** Match valid CSS numeric values: 0, 12px, 1.5rem, 100%, -2em, etc. */
122
+ const CSS_NUMERIC_RE = /^-?\d+(\.\d+)?(px|rem|em|%|vh|vw|pt|ch|ex|vmin|vmax)?$/;
123
+
124
+ /** Match valid CSS font-weight values */
125
+ const FONT_WEIGHT_RE = /^(normal|bold|bolder|lighter|\d{3})$/;
126
+
127
+ /** Match valid font-style values */
128
+ const FONT_STYLE_RE = /^(normal|italic|oblique)$/;
129
+
130
+ /** Match safe font-family names: alphanumeric, spaces, hyphens */
131
+ const FONT_FAMILY_RE = /^[a-zA-Z0-9\s\-_]+$/;
132
+
133
+ /** Match safe font file URL — must be relative path or https */
134
+ const FONT_URL_RE = /^(\/[a-zA-Z0-9\-_/.]+|https:\/\/[a-zA-Z0-9\-_.]+\.[a-z]{2,}\/[^\s'"()]+)$/;
135
+
136
+ /**
137
+ * Sanitize a CSS color value. Returns fallback if invalid.
138
+ */
139
+ export function sanitizeCssColor(value: string, fallback: string = "#000000"): string {
140
+ if (!value || typeof value !== "string") return fallback;
141
+ const v = value.trim();
142
+ // Allow hex colors
143
+ if (HEX_COLOR_RE.test(v)) return v;
144
+ // Allow named CSS colors (basic set)
145
+ if (/^[a-zA-Z]{3,20}$/.test(v)) return v;
146
+ // Allow rgb()/rgba()/hsl()/hsla() — validate no url() or expressions
147
+ if (/^(rgb|hsl)a?\(\s*[\d.,\s%]+\)$/.test(v)) return v;
148
+ return fallback;
149
+ }
150
+
151
+ /**
152
+ * Sanitize a CSS numeric value (sizes, spacing). Returns fallback if invalid.
153
+ */
154
+ export function sanitizeCssNumeric(value: string, fallback: string = "0"): string {
155
+ if (!value || typeof value !== "string") return fallback;
156
+ const v = value.trim();
157
+ if (CSS_NUMERIC_RE.test(v)) return v;
158
+ return fallback;
159
+ }
160
+
161
+ /**
162
+ * Sanitize a font family name. Returns fallback if invalid.
163
+ */
164
+ export function sanitizeFontFamily(value: string, fallback: string = "monospace"): string {
165
+ if (!value || typeof value !== "string") return fallback;
166
+ const v = value.trim();
167
+ if (FONT_FAMILY_RE.test(v)) return v;
168
+ return fallback;
169
+ }
170
+
171
+ /**
172
+ * Sanitize a font weight value.
173
+ */
174
+ export function sanitizeFontWeight(value: string, fallback: string = "400"): string {
175
+ if (!value || typeof value !== "string") return fallback;
176
+ const v = value.trim();
177
+ if (FONT_WEIGHT_RE.test(v)) return v;
178
+ return fallback;
179
+ }
180
+
181
+ /**
182
+ * Sanitize a font style value.
183
+ */
184
+ export function sanitizeFontStyle(value: string, fallback: string = "normal"): string {
185
+ if (!value || typeof value !== "string") return fallback;
186
+ const v = value.trim();
187
+ if (FONT_STYLE_RE.test(v)) return v;
188
+ return fallback;
189
+ }
190
+
191
+ /**
192
+ * Sanitize a font file URL. Returns empty string if invalid.
193
+ */
194
+ export function sanitizeFontUrl(value: string): string {
195
+ if (!value || typeof value !== "string") return "";
196
+ const v = value.trim();
197
+ if (FONT_URL_RE.test(v)) return v;
198
+ return "";
199
+ }
200
+
201
+ /**
202
+ * Sanitize a CSS underline/decoration value.
203
+ */
204
+ export function sanitizeCssDecoration(value: string, fallback: string = "none"): string {
205
+ const allowed = ["none", "underline", "overline", "line-through"];
206
+ if (!value || typeof value !== "string") return fallback;
207
+ return allowed.includes(value.trim()) ? value.trim() : fallback;
208
+ }
209
+
210
+ // ─── Asset Path Validation ────────────────────────────────────────────────
211
+
212
+ /**
213
+ * Validate an asset path. Rejects path traversal and absolute paths.
214
+ */
215
+ export function isValidAssetPath(path: string): boolean {
216
+ if (!path || typeof path !== "string") return false;
217
+ const trimmed = path.trim();
218
+ // #34: Reject empty, slash-only, absolute paths, or path traversal
219
+ if (!trimmed) return false;
220
+ if (trimmed.startsWith("/")) return false;
221
+ if (trimmed.includes("..")) return false;
222
+ // #34: Reject slash-only paths or trailing-only slash paths
223
+ if (trimmed.replace(/\//g, "") === "") return false;
224
+ // Reject query strings or fragments
225
+ if (trimmed.includes("?") || trimmed.includes("#")) return false;
226
+ // Reject control characters and null bytes
227
+ if (/[\x00-\x1f\x7f]/.test(trimmed)) return false;
228
+ return true;
229
+ }
230
+
231
+ // ─── Font Magic Byte Validation ───────────────────────────────────────────
232
+
233
+ interface FontMagicBytes {
234
+ bytes: number[];
235
+ offset: number;
236
+ }
237
+
238
+ const FONT_MAGIC: Record<string, FontMagicBytes[]> = {
239
+ ".woff2": [{ bytes: [0x77, 0x4f, 0x46, 0x32], offset: 0 }], // wOF2
240
+ ".woff": [{ bytes: [0x77, 0x4f, 0x46, 0x46], offset: 0 }], // wOFF
241
+ ".ttf": [
242
+ { bytes: [0x00, 0x01, 0x00, 0x00], offset: 0 }, // TrueType
243
+ { bytes: [0x74, 0x72, 0x75, 0x65], offset: 0 }, // 'true'
244
+ ],
245
+ ".otf": [{ bytes: [0x4f, 0x54, 0x54, 0x4f], offset: 0 }], // OTTO
246
+ };
247
+
248
+ /**
249
+ * Validate that a file's magic bytes match the expected font format.
250
+ */
251
+ export function validateFontMagicBytes(
252
+ buffer: ArrayBuffer,
253
+ extension: string
254
+ ): boolean {
255
+ const magicList = FONT_MAGIC[extension];
256
+ if (!magicList) return false;
257
+
258
+ const view = new Uint8Array(buffer);
259
+ if (view.length < 4) return false;
260
+
261
+ return magicList.some((magic) =>
262
+ magic.bytes.every(
263
+ (byte, i) => view[magic.offset + i] === byte
264
+ )
265
+ );
266
+ }
267
+
268
+ // ─── Token Encryption (for credentials at rest) ──────────────────────────
269
+
270
+ const ENCRYPTION_ALGORITHM = "AES-GCM";
271
+ const IV_LENGTH = 12; // 96 bits for AES-GCM
272
+
273
+ /**
274
+ * Get or derive an encryption key from ADMIN_TOKEN_SECRET.
275
+ */
276
+ async function getEncryptionKey(): Promise<CryptoKey | null> {
277
+ const secret = process.env.ADMIN_TOKEN_SECRET;
278
+ if (!secret) return null;
279
+
280
+ // Derive a 256-bit key from the secret using SHA-256
281
+ const encoder = new TextEncoder();
282
+ const hash = await crypto.subtle.digest("SHA-256", encoder.encode(secret));
283
+
284
+ return crypto.subtle.importKey(
285
+ "raw",
286
+ hash,
287
+ { name: ENCRYPTION_ALGORITHM },
288
+ false,
289
+ ["encrypt", "decrypt"]
290
+ );
291
+ }
292
+
293
+ /**
294
+ * Encrypt a plaintext string. Returns base64-encoded "iv:ciphertext".
295
+ * Returns the plaintext as-is if no encryption key is configured.
296
+ */
297
+ export async function encryptToken(plaintext: string): Promise<string> {
298
+ const key = await getEncryptionKey();
299
+ if (!key) return plaintext; // Graceful fallback if no secret configured
300
+
301
+ const encoder = new TextEncoder();
302
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
303
+
304
+ const ciphertext = await crypto.subtle.encrypt(
305
+ { name: ENCRYPTION_ALGORITHM, iv },
306
+ key,
307
+ encoder.encode(plaintext)
308
+ );
309
+
310
+ // Encode as "enc:base64(iv):base64(ciphertext)"
311
+ const ivB64 = btoa(String.fromCharCode(...iv));
312
+ const ctB64 = btoa(String.fromCharCode(...new Uint8Array(ciphertext)));
313
+ return `enc:${ivB64}:${ctB64}`;
314
+ }
315
+
316
+ /**
317
+ * Decrypt a token string. Handles both encrypted ("enc:...") and
318
+ * legacy plaintext tokens for backward compatibility.
319
+ *
320
+ * #5: Wrapped in try/catch to prevent crashes from corrupted tokens.
321
+ * Throws a descriptive error instead of raw atob/crypto failures.
322
+ */
323
+ export async function decryptToken(stored: string): Promise<string> {
324
+ if (!stored || typeof stored !== "string") {
325
+ throw new Error("Cannot decrypt: empty or invalid token value");
326
+ }
327
+
328
+ // Not encrypted — return as-is (legacy/fallback)
329
+ if (!stored.startsWith("enc:")) return stored;
330
+
331
+ const key = await getEncryptionKey();
332
+ if (!key) {
333
+ throw new Error("Cannot decrypt token: ADMIN_TOKEN_SECRET not configured");
334
+ }
335
+
336
+ const parts = stored.split(":");
337
+ if (parts.length !== 3) {
338
+ throw new Error("Invalid encrypted token format — expected enc:iv:ciphertext");
339
+ }
340
+
341
+ try {
342
+ const iv = Uint8Array.from(atob(parts[1]), (c) => c.charCodeAt(0));
343
+ const ciphertext = Uint8Array.from(atob(parts[2]), (c) => c.charCodeAt(0));
344
+
345
+ const plaintext = await crypto.subtle.decrypt(
346
+ { name: ENCRYPTION_ALGORITHM, iv },
347
+ key,
348
+ ciphertext
349
+ );
350
+
351
+ return new TextDecoder().decode(plaintext);
352
+ } catch (err) {
353
+ // #5: Provide a meaningful error instead of raw atob/crypto crash
354
+ throw new Error(
355
+ `Failed to decrypt token: ${err instanceof Error ? err.message : "corrupted data"}. ` +
356
+ "Credentials may be corrupted in Sanity. Please reconnect the storage provider."
357
+ );
358
+ }
359
+ }
360
+
361
+ // ─── Rate Limiting (#32) ──────────────────────────────────────────────────
362
+
363
+ interface RateLimitBucket {
364
+ count: number;
365
+ windowStart: number;
366
+ }
367
+
368
+ const rateLimitBuckets = new Map<string, RateLimitBucket>();
369
+ let lastRateLimitCleanup = Date.now();
370
+
371
+ function cleanupRateLimitBuckets(windowMs: number) {
372
+ const now = Date.now();
373
+ if (now - lastRateLimitCleanup < 60_000) return; // cleanup max once per minute
374
+ lastRateLimitCleanup = now;
375
+ for (const [key, bucket] of rateLimitBuckets) {
376
+ if (now - bucket.windowStart > windowMs) {
377
+ rateLimitBuckets.delete(key);
378
+ }
379
+ }
380
+ }
381
+
382
+ /**
383
+ * #32: Simple in-memory rate limiter for mutation endpoints.
384
+ * Returns true if the request should be allowed, false if rate limited.
385
+ *
386
+ * @param key - Unique key for the rate limit bucket (e.g. "r2-delete:IP")
387
+ * @param maxRequests - Max requests per window
388
+ * @param windowMs - Window size in milliseconds
389
+ */
390
+ export function checkRateLimit(key: string, maxRequests: number, windowMs: number): boolean {
391
+ cleanupRateLimitBuckets(windowMs);
392
+ const now = Date.now();
393
+ const bucket = rateLimitBuckets.get(key);
394
+
395
+ if (!bucket || now - bucket.windowStart > windowMs) {
396
+ rateLimitBuckets.set(key, { count: 1, windowStart: now });
397
+ return true;
398
+ }
399
+
400
+ bucket.count++;
401
+ return bucket.count <= maxRequests;
402
+ }
@@ -0,0 +1,156 @@
1
+ import { client } from "../../lib/sanity/client";
2
+
3
+ /**
4
+ * Setup Detection — checks whether a new instance has been configured.
5
+ *
6
+ * The wizard guides new instances through: Database → Storage → Branding.
7
+ * An already-configured instance (like the root Morphika deployment) skips
8
+ * the wizard entirely because all three checks pass.
9
+ *
10
+ * Each step check is independent so the wizard can resume from where the
11
+ * user left off.
12
+ *
13
+ * Completion flag: When the wizard's "Start Building" button is clicked,
14
+ * `setup_complete: true` is written to siteSettings. This ensures:
15
+ * - Users who skipped optional steps don't see the wizard again
16
+ * - Already-configured instances (Morphika) pass via `isSetupComplete()`
17
+ * because `setup_complete` is set, OR all individual checks pass
18
+ */
19
+
20
+ // ── Individual step checks ──
21
+
22
+ /**
23
+ * Check if Sanity is connected and the core documents exist.
24
+ * Returns true when siteSettings, siteStyles, and assetRegistry docs are present.
25
+ */
26
+ export async function isDatabaseConfigured(): Promise<boolean> {
27
+ const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID;
28
+ if (!projectId) return false;
29
+
30
+ try {
31
+ const counts = await client.fetch<{
32
+ siteSettings: number;
33
+ siteStyles: number;
34
+ assetRegistry: number;
35
+ }>(`{
36
+ "siteSettings": count(*[_type == "siteSettings"]),
37
+ "siteStyles": count(*[_type == "siteStyles"]),
38
+ "assetRegistry": count(*[_type == "assetRegistry"])
39
+ }`);
40
+
41
+ return (
42
+ counts.siteSettings > 0 &&
43
+ counts.siteStyles > 0 &&
44
+ counts.assetRegistry > 0
45
+ );
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Check if a storage provider (R2) is connected.
53
+ * Reads the assetRegistry document for a non-empty storage_provider field.
54
+ */
55
+ export async function isStorageConfigured(): Promise<boolean> {
56
+ const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID;
57
+ if (!projectId) return false;
58
+
59
+ try {
60
+ const result = await client.fetch<{ provider: string | null }>(
61
+ `*[_type == "assetRegistry"][0]{ "provider": storage_provider }`
62
+ );
63
+ return !!result?.provider;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Check if branding has been configured.
71
+ * True when siteStyles has at least one font or a non-empty color palette,
72
+ * OR siteSettings has a non-empty default_title.
73
+ */
74
+ export async function isBrandingConfigured(): Promise<boolean> {
75
+ const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID;
76
+ if (!projectId) return false;
77
+
78
+ try {
79
+ const result = await client.fetch<{
80
+ hasTitle: boolean;
81
+ hasPalette: boolean;
82
+ hasFonts: boolean;
83
+ }>(`{
84
+ "hasTitle": count(*[_type == "siteSettings" && defined(default_title) && default_title != ""]) > 0,
85
+ "hasPalette": count(*[_type == "siteStyles" && defined(color_palette) && length(color_palette) > 0]) > 0,
86
+ "hasFonts": count(*[_type == "siteStyles" && defined(fonts) && length(fonts) > 0]) > 0
87
+ }`);
88
+
89
+ // Branding is "done" if the user has set a title or customized colors/fonts
90
+ return result.hasTitle || result.hasPalette || result.hasFonts;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Check if the setup_complete flag has been explicitly set on siteSettings.
98
+ * This is set when the user clicks "Start Building" in the wizard.
99
+ */
100
+ export async function isSetupMarkedComplete(): Promise<boolean> {
101
+ const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID;
102
+ if (!projectId) return false;
103
+
104
+ try {
105
+ const result = await client.fetch<{ complete: boolean }>(
106
+ `*[_type == "siteSettings"][0]{ "complete": setup_complete == true }`
107
+ );
108
+ return !!result?.complete;
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ // ── Aggregate check ──
115
+
116
+ export interface SetupStatus {
117
+ complete: boolean;
118
+ steps: {
119
+ database: boolean;
120
+ storage: boolean;
121
+ branding: boolean;
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Full setup status — used by the admin layout to decide whether to
127
+ * show the onboarding wizard.
128
+ *
129
+ * Setup is "complete" when:
130
+ * - The explicit `setup_complete` flag is set (user finished wizard), OR
131
+ * - All three individual checks pass (pre-existing configured instance)
132
+ */
133
+ export async function getSetupStatus(): Promise<SetupStatus> {
134
+ const [database, storage, branding, markedComplete] = await Promise.all([
135
+ isDatabaseConfigured(),
136
+ isStorageConfigured(),
137
+ isBrandingConfigured(),
138
+ isSetupMarkedComplete(),
139
+ ]);
140
+
141
+ // Complete if explicitly marked OR all individual checks pass
142
+ const complete = markedComplete || (database && storage && branding);
143
+
144
+ return {
145
+ complete,
146
+ steps: { database, storage, branding },
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Quick boolean check — true when the instance is fully configured.
152
+ */
153
+ export async function isSetupComplete(): Promise<boolean> {
154
+ const status = await getSetupStatus();
155
+ return status.complete;
156
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * GLSL shader lookup — maps preset ID to fragment shader source.
3
+ *
4
+ * Session 71.
5
+ */
6
+
7
+ import type { ShaderHoverPreset } from "../../../lib/animation/hover-effect-types";
8
+ import { rippleFragment } from "./ripple";
9
+ import { rgbShiftFragment } from "./rgb-shift";
10
+ import { pixelateFragment } from "./pixelate";
11
+
12
+ const FRAGMENT_SHADERS: Partial<Record<ShaderHoverPreset, string>> = {
13
+ ripple: rippleFragment,
14
+ "rgb-shift": rgbShiftFragment,
15
+ pixelate: pixelateFragment,
16
+ };
17
+
18
+ /**
19
+ * Get the fragment shader source for a given preset.
20
+ * Returns undefined for "none" or unknown presets.
21
+ */
22
+ export function getFragmentShader(preset: ShaderHoverPreset | "none"): string | undefined {
23
+ if (preset === "none") return undefined;
24
+ return FRAGMENT_SHADERS[preset];
25
+ }
26
+
27
+ export { vertexShader } from "./vertex";
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Pixelate fragment shader.
3
+ *
4
+ * Quantizes UV coordinates to a grid — sharp image that pixelates
5
+ * on hover (uHover 0→1 = sharp→pixelated).
6
+ *
7
+ * Uniforms:
8
+ * uTexture — image texture
9
+ * uMouse — normalized cursor position (0–1)
10
+ * uIntensity — config intensity (0.5–2.0)
11
+ * uSpeed — config speed (0.5–3.0) — controls transition curve
12
+ * uTime — elapsed time (seconds) — unused for this preset
13
+ * uHover — lerped hover state (0=not hovering, 1=hovering)
14
+ * uResolution — canvas size in pixels
15
+ *
16
+ * Session 72 — original scroll-driven shader.
17
+ * Session 123 — inverted for hover trigger (0=sharp, 1=pixelated).
18
+ */
19
+ export const pixelateFragment = /* glsl */ `
20
+ precision highp float;
21
+
22
+ uniform sampler2D uTexture;
23
+ uniform vec2 uMouse;
24
+ uniform float uIntensity;
25
+ uniform float uSpeed;
26
+ uniform float uTime;
27
+ uniform float uHover;
28
+ uniform vec2 uResolution;
29
+
30
+ varying vec2 vUv;
31
+
32
+ void main() {
33
+ vec2 uv = vUv;
34
+
35
+ // Hover drives pixelation: 0=sharp (not hovering), 1=fully pixelated (hovering)
36
+ float hover = clamp(uHover, 0.0, 1.0);
37
+
38
+ // Pixel grid size — from tiny (1px = sharp) to large (48px blocks)
39
+ float maxPixelSize = 48.0 * uIntensity;
40
+ float pixelSize = mix(1.0, maxPixelSize, pow(hover, uSpeed));
41
+
42
+ // Quantize UV to pixel grid
43
+ vec2 gridSize = uResolution / pixelSize;
44
+ vec2 quantized = floor(uv * gridSize + 0.5) / gridSize;
45
+
46
+ // Smooth blend — near sharp end, interpolate for anti-alias
47
+ vec2 finalUv = mix(uv, quantized, smoothstep(0.0, 0.15, hover));
48
+
49
+ gl_FragColor = texture2D(uTexture, finalUv);
50
+ }
51
+ `;