modern-cms 1.0.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.
Files changed (180) hide show
  1. package/README.md +41 -0
  2. package/bin/lib.js +97 -0
  3. package/bin/modern-cms.js +197 -0
  4. package/package.json +37 -0
  5. package/template/.env.example +48 -0
  6. package/template/README.md +210 -0
  7. package/template/apps/api/Dockerfile +26 -0
  8. package/template/apps/api/package.json +45 -0
  9. package/template/apps/api/prisma/backfill-search-text.ts +31 -0
  10. package/template/apps/api/prisma/migrations/20260701034530_init/migration.sql +86 -0
  11. package/template/apps/api/prisma/migrations/20260701081942_add_publish_at_and_revisions/migration.sql +19 -0
  12. package/template/apps/api/prisma/migrations/20260701085011_add_form_submissions/migration.sql +13 -0
  13. package/template/apps/api/prisma/migrations/20260702084049_add_global_sections_and_saved_blocks/migration.sql +24 -0
  14. package/template/apps/api/prisma/migrations/20260702085609_add_page_locale_and_translations/migration.sql +6 -0
  15. package/template/apps/api/prisma/migrations/20260702091914_add_collections/migration.sql +38 -0
  16. package/template/apps/api/prisma/migrations/20260702120000_platform_hardening/migration.sql +15 -0
  17. package/template/apps/api/prisma/migrations/20260705031230_add_collections_redirects_media_meta/migration.sql +17 -0
  18. package/template/apps/api/prisma/migrations/20260705033241_add_audit_log/migration.sql +18 -0
  19. package/template/apps/api/prisma/migrations/20260706031301_standard_cms_platform_pass/migration.sql +48 -0
  20. package/template/apps/api/prisma/migrations/migration_lock.toml +3 -0
  21. package/template/apps/api/prisma/reset-admin.ts +19 -0
  22. package/template/apps/api/prisma/schema.prisma +251 -0
  23. package/template/apps/api/prisma/seed-homepage.ts +752 -0
  24. package/template/apps/api/prisma/seed-our-charities.ts +231 -0
  25. package/template/apps/api/prisma/seed-templates.ts +154 -0
  26. package/template/apps/api/prisma/seed.ts +191 -0
  27. package/template/apps/api/src/env.ts +61 -0
  28. package/template/apps/api/src/index.ts +66 -0
  29. package/template/apps/api/src/lib/revalidate.ts +28 -0
  30. package/template/apps/api/src/lib/storage/azure.ts +25 -0
  31. package/template/apps/api/src/lib/storage/cloudinary.ts +42 -0
  32. package/template/apps/api/src/lib/storage/index.ts +25 -0
  33. package/template/apps/api/src/lib/storage/s3.ts +39 -0
  34. package/template/apps/api/src/lib/storage/types.ts +8 -0
  35. package/template/apps/api/src/middleware/auditLog.ts +32 -0
  36. package/template/apps/api/src/middleware/auth.ts +53 -0
  37. package/template/apps/api/src/middleware/rateLimit.ts +31 -0
  38. package/template/apps/api/src/middleware/securityHeaders.ts +10 -0
  39. package/template/apps/api/src/prisma.ts +3 -0
  40. package/template/apps/api/src/routes/audit.routes.ts +26 -0
  41. package/template/apps/api/src/routes/auth.routes.ts +46 -0
  42. package/template/apps/api/src/routes/backup.routes.ts +216 -0
  43. package/template/apps/api/src/routes/blocks.routes.ts +38 -0
  44. package/template/apps/api/src/routes/collections.routes.ts +185 -0
  45. package/template/apps/api/src/routes/forms.routes.ts +114 -0
  46. package/template/apps/api/src/routes/globalSections.routes.ts +43 -0
  47. package/template/apps/api/src/routes/media.routes.ts +77 -0
  48. package/template/apps/api/src/routes/pages.routes.ts +319 -0
  49. package/template/apps/api/src/routes/redirects.routes.ts +52 -0
  50. package/template/apps/api/src/routes/search.routes.ts +83 -0
  51. package/template/apps/api/src/routes/templates.routes.ts +41 -0
  52. package/template/apps/api/src/routes/theme.routes.ts +75 -0
  53. package/template/apps/api/src/routes/users.routes.ts +39 -0
  54. package/template/apps/api/tsconfig.json +11 -0
  55. package/template/apps/web/Dockerfile +26 -0
  56. package/template/apps/web/app/[[...slug]]/page.tsx +254 -0
  57. package/template/apps/web/app/admin/(protected)/audit/page.tsx +80 -0
  58. package/template/apps/web/app/admin/(protected)/collections/item/[itemId]/page.tsx +375 -0
  59. package/template/apps/web/app/admin/(protected)/collections/page.tsx +224 -0
  60. package/template/apps/web/app/admin/(protected)/forms/page.tsx +88 -0
  61. package/template/apps/web/app/admin/(protected)/layout.tsx +5 -0
  62. package/template/apps/web/app/admin/(protected)/media/page.tsx +138 -0
  63. package/template/apps/web/app/admin/(protected)/page.tsx +186 -0
  64. package/template/apps/web/app/admin/(protected)/pages/[id]/page.tsx +560 -0
  65. package/template/apps/web/app/admin/(protected)/settings/backup/page.tsx +97 -0
  66. package/template/apps/web/app/admin/(protected)/settings/global-sections/page.tsx +333 -0
  67. package/template/apps/web/app/admin/(protected)/settings/navigation/page.tsx +192 -0
  68. package/template/apps/web/app/admin/(protected)/settings/redirects/page.tsx +108 -0
  69. package/template/apps/web/app/admin/(protected)/settings/theme/page.tsx +239 -0
  70. package/template/apps/web/app/admin/(protected)/users/page.tsx +94 -0
  71. package/template/apps/web/app/admin/login/page.tsx +76 -0
  72. package/template/apps/web/app/api/revalidate/route.ts +34 -0
  73. package/template/apps/web/app/globals.css +120 -0
  74. package/template/apps/web/app/layout.tsx +39 -0
  75. package/template/apps/web/app/preview/[token]/page.tsx +70 -0
  76. package/template/apps/web/app/robots.ts +11 -0
  77. package/template/apps/web/app/sitemap.ts +27 -0
  78. package/template/apps/web/components/admin/AdminShell.tsx +104 -0
  79. package/template/apps/web/components/builder/BuilderCanvasNode.tsx +546 -0
  80. package/template/apps/web/components/builder/BuilderDeviceContext.tsx +13 -0
  81. package/template/apps/web/components/builder/Canvas.tsx +76 -0
  82. package/template/apps/web/components/builder/CanvasOverlayContext.tsx +22 -0
  83. package/template/apps/web/components/builder/ColorPickerField.tsx +47 -0
  84. package/template/apps/web/components/builder/DroppableChildren.tsx +50 -0
  85. package/template/apps/web/components/builder/FieldRenderer.tsx +277 -0
  86. package/template/apps/web/components/builder/IconRenderer.tsx +7 -0
  87. package/template/apps/web/components/builder/Inspector.tsx +956 -0
  88. package/template/apps/web/components/builder/JsonEditor.tsx +71 -0
  89. package/template/apps/web/components/builder/KeyValueListEditor.tsx +76 -0
  90. package/template/apps/web/components/builder/MediaPickerModal.tsx +95 -0
  91. package/template/apps/web/components/builder/PageSettingsModal.tsx +363 -0
  92. package/template/apps/web/components/builder/Palette.tsx +106 -0
  93. package/template/apps/web/components/builder/RichTextEditor.tsx +140 -0
  94. package/template/apps/web/components/renderer/CmsImage.tsx +46 -0
  95. package/template/apps/web/components/renderer/NodeBackgroundLayers.tsx +35 -0
  96. package/template/apps/web/components/renderer/PublicPageView.tsx +56 -0
  97. package/template/apps/web/components/renderer/Renderer.tsx +146 -0
  98. package/template/apps/web/components/renderer/RendererContext.tsx +20 -0
  99. package/template/apps/web/components/renderer/registry.tsx +87 -0
  100. package/template/apps/web/components/renderer/sections/Accordion.tsx +39 -0
  101. package/template/apps/web/components/renderer/sections/BeforeAfter.tsx +75 -0
  102. package/template/apps/web/components/renderer/sections/Button.tsx +42 -0
  103. package/template/apps/web/components/renderer/sections/ButtonGroup.tsx +22 -0
  104. package/template/apps/web/components/renderer/sections/CardGrid.tsx +66 -0
  105. package/template/apps/web/components/renderer/sections/CollectionList.tsx +84 -0
  106. package/template/apps/web/components/renderer/sections/Column.tsx +26 -0
  107. package/template/apps/web/components/renderer/sections/ContactForm.tsx +96 -0
  108. package/template/apps/web/components/renderer/sections/Container.tsx +44 -0
  109. package/template/apps/web/components/renderer/sections/ContentLinks.tsx +38 -0
  110. package/template/apps/web/components/renderer/sections/Countdown.tsx +69 -0
  111. package/template/apps/web/components/renderer/sections/Cta.tsx +15 -0
  112. package/template/apps/web/components/renderer/sections/Divider.tsx +26 -0
  113. package/template/apps/web/components/renderer/sections/EmbedBlock.tsx +43 -0
  114. package/template/apps/web/components/renderer/sections/Footer.tsx +117 -0
  115. package/template/apps/web/components/renderer/sections/Gallery.tsx +27 -0
  116. package/template/apps/web/components/renderer/sections/GoToTop.tsx +54 -0
  117. package/template/apps/web/components/renderer/sections/Grid.tsx +54 -0
  118. package/template/apps/web/components/renderer/sections/GridCell.tsx +33 -0
  119. package/template/apps/web/components/renderer/sections/Header.tsx +132 -0
  120. package/template/apps/web/components/renderer/sections/Hero.tsx +73 -0
  121. package/template/apps/web/components/renderer/sections/ImageBackgroundSection.tsx +20 -0
  122. package/template/apps/web/components/renderer/sections/ImageBlock.tsx +55 -0
  123. package/template/apps/web/components/renderer/sections/LanguageSwitcher.tsx +51 -0
  124. package/template/apps/web/components/renderer/sections/Logo.tsx +24 -0
  125. package/template/apps/web/components/renderer/sections/LogoCloud.tsx +67 -0
  126. package/template/apps/web/components/renderer/sections/MapEmbed.tsx +24 -0
  127. package/template/apps/web/components/renderer/sections/Marquee.tsx +45 -0
  128. package/template/apps/web/components/renderer/sections/NavLinks.tsx +69 -0
  129. package/template/apps/web/components/renderer/sections/NavZone.tsx +19 -0
  130. package/template/apps/web/components/renderer/sections/PricingTable.tsx +65 -0
  131. package/template/apps/web/components/renderer/sections/RichText.tsx +7 -0
  132. package/template/apps/web/components/renderer/sections/SiteSearch.tsx +67 -0
  133. package/template/apps/web/components/renderer/sections/Slider.tsx +81 -0
  134. package/template/apps/web/components/renderer/sections/Spacer.tsx +8 -0
  135. package/template/apps/web/components/renderer/sections/Stats.tsx +69 -0
  136. package/template/apps/web/components/renderer/sections/Tabs.tsx +59 -0
  137. package/template/apps/web/components/renderer/sections/TeamGrid.tsx +49 -0
  138. package/template/apps/web/components/renderer/sections/Testimonial.tsx +79 -0
  139. package/template/apps/web/components/renderer/sections/ThreeBackground.tsx +43 -0
  140. package/template/apps/web/components/renderer/sections/Timeline.tsx +29 -0
  141. package/template/apps/web/components/renderer/sections/VideoBackgroundSection.tsx +20 -0
  142. package/template/apps/web/components/renderer/sections/VideoEmbed.tsx +70 -0
  143. package/template/apps/web/components/renderer/sections/types.ts +7 -0
  144. package/template/apps/web/components/seo/JsonLd.tsx +111 -0
  145. package/template/apps/web/components/theme/AnalyticsScripts.tsx +68 -0
  146. package/template/apps/web/components/theme/DarkModeToggle.tsx +39 -0
  147. package/template/apps/web/components/theme/SiteDataContext.tsx +39 -0
  148. package/template/apps/web/components/theme/ThemeProvider.tsx +58 -0
  149. package/template/apps/web/eslint.config.mjs +16 -0
  150. package/template/apps/web/lib/api.ts +45 -0
  151. package/template/apps/web/lib/responsiveCss.ts +42 -0
  152. package/template/apps/web/lib/sanitizeHtml.ts +16 -0
  153. package/template/apps/web/lib/serverApi.ts +25 -0
  154. package/template/apps/web/lib/style.ts +73 -0
  155. package/template/apps/web/lib/threeBackgroundScene.ts +298 -0
  156. package/template/apps/web/lib/useCurrentUser.ts +35 -0
  157. package/template/apps/web/lib/useEntranceAnimation.ts +245 -0
  158. package/template/apps/web/next-env.d.ts +6 -0
  159. package/template/apps/web/next.config.mjs +32 -0
  160. package/template/apps/web/package.json +46 -0
  161. package/template/apps/web/postcss.config.mjs +6 -0
  162. package/template/apps/web/proxy.ts +23 -0
  163. package/template/apps/web/tailwind.config.ts +24 -0
  164. package/template/apps/web/tsconfig.json +41 -0
  165. package/template/docker-compose.yml +60 -0
  166. package/template/package.json +29 -0
  167. package/template/packages/shared/package.json +21 -0
  168. package/template/packages/shared/src/components.ts +1626 -0
  169. package/template/packages/shared/src/index.ts +9 -0
  170. package/template/packages/shared/src/schema/collections.ts +70 -0
  171. package/template/packages/shared/src/schema/fields.ts +26 -0
  172. package/template/packages/shared/src/schema/media.ts +13 -0
  173. package/template/packages/shared/src/schema/pageNode.ts +255 -0
  174. package/template/packages/shared/src/schema/templates.ts +20 -0
  175. package/template/packages/shared/src/schema/theme.ts +69 -0
  176. package/template/packages/shared/src/schema/user.ts +25 -0
  177. package/template/packages/shared/src/tree.test.ts +65 -0
  178. package/template/packages/shared/src/tree.ts +303 -0
  179. package/template/packages/shared/tsconfig.json +8 -0
  180. package/template/packages/shared/vitest.config.ts +8 -0
@@ -0,0 +1,38 @@
1
+ import { Router } from "express";
2
+ import { PageNodeSchema } from "@pgcms/shared";
3
+ import { z } from "zod";
4
+ import { prisma } from "../prisma.js";
5
+ import { requireAuth, requireRole } from "../middleware/auth.js";
6
+
7
+ export const blocksRouter = Router();
8
+
9
+ const CreateBlockSchema = z.object({
10
+ name: z.string().min(1).max(80),
11
+ content: PageNodeSchema,
12
+ });
13
+
14
+ // The whole block library is builder-only, so every route requires an editor session.
15
+ blocksRouter.use(requireAuth, requireRole("ADMIN", "EDITOR"));
16
+
17
+ blocksRouter.get("/", async (_req, res) => {
18
+ const blocks = await prisma.savedBlock.findMany({ orderBy: { createdAt: "desc" } });
19
+ res.json(blocks);
20
+ });
21
+
22
+ blocksRouter.post("/", async (req, res) => {
23
+ const parsed = CreateBlockSchema.safeParse(req.body);
24
+ if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
25
+ const block = await prisma.savedBlock.create({
26
+ data: { name: parsed.data.name, content: parsed.data.content as object },
27
+ });
28
+ res.status(201).json(block);
29
+ });
30
+
31
+ blocksRouter.delete("/:id", async (req, res) => {
32
+ try {
33
+ await prisma.savedBlock.delete({ where: { id: req.params.id } });
34
+ res.json({ ok: true });
35
+ } catch {
36
+ res.status(404).json({ error: "Block not found" });
37
+ }
38
+ });
@@ -0,0 +1,185 @@
1
+ import { Router } from "express";
2
+ import {
3
+ CreateCollectionInputSchema,
4
+ CreateCollectionItemInputSchema,
5
+ UpdateCollectionItemInputSchema,
6
+ createEmptyPage,
7
+ extractSearchText,
8
+ type PageNode,
9
+ } from "@pgcms/shared";
10
+ import { prisma } from "../prisma.js";
11
+ import { requireAuth, requireRole, canModify } from "../middleware/auth.js";
12
+ import { triggerRevalidation } from "../lib/revalidate.js";
13
+
14
+ export const collectionsRouter = Router();
15
+
16
+ // --- Public reads (no auth) ---
17
+
18
+ // Newest-first published items of a collection — powers the "Collection List" block.
19
+ collectionsRouter.get("/public/:slug/items", async (req, res) => {
20
+ const collection = await prisma.collection.findUnique({ where: { slug: req.params.slug } });
21
+ if (!collection) return res.status(404).json({ error: "Collection not found" });
22
+ const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 12));
23
+ const items = await prisma.collectionItem.findMany({
24
+ where: { collectionId: collection.id, status: "PUBLISHED" },
25
+ orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }],
26
+ take: limit,
27
+ select: { slug: true, title: true, excerpt: true, coverImage: true, publishedAt: true },
28
+ });
29
+ res.json(items);
30
+ });
31
+
32
+ collectionsRouter.get("/public/:slug/items/:itemSlug", async (req, res) => {
33
+ const collection = await prisma.collection.findUnique({ where: { slug: req.params.slug } });
34
+ if (!collection) return res.status(404).json({ error: "Not found" });
35
+ const item = await prisma.collectionItem.findFirst({
36
+ where: { collectionId: collection.id, slug: req.params.itemSlug, status: "PUBLISHED" },
37
+ });
38
+ if (!item) return res.status(404).json({ error: "Not found" });
39
+ res.json({ ...item, collectionSlug: collection.slug, collectionName: collection.name });
40
+ });
41
+
42
+ // All publicly-visible members of an item's translation group, mirroring
43
+ // pages.routes.ts's /public-translations — the group root is the item's
44
+ // translationOfId (or the item itself when it IS the root), scoped to items within the
45
+ // same collection since a "translation of" only makes sense there.
46
+ collectionsRouter.get("/public/:slug/items/:itemSlug/translations", async (req, res) => {
47
+ const collection = await prisma.collection.findUnique({ where: { slug: req.params.slug } });
48
+ if (!collection) return res.json([]);
49
+ const item = await prisma.collectionItem.findFirst({
50
+ where: { collectionId: collection.id, slug: req.params.itemSlug },
51
+ });
52
+ if (!item) return res.json([]);
53
+ const rootId = item.translationOfId ?? item.id;
54
+ const group = await prisma.collectionItem.findMany({
55
+ where: {
56
+ collectionId: collection.id,
57
+ OR: [{ id: rootId }, { translationOfId: rootId }],
58
+ status: "PUBLISHED",
59
+ },
60
+ select: { locale: true, slug: true, title: true },
61
+ });
62
+ res.json(group.map(({ locale, slug, title }) => ({ locale, slug: `${collection.slug}/${slug}`, title })));
63
+ });
64
+
65
+ // Everything the sitemap needs in one call: all published item URLs.
66
+ collectionsRouter.get("/public-sitemap", async (_req, res) => {
67
+ const items = await prisma.collectionItem.findMany({
68
+ where: { status: "PUBLISHED" },
69
+ select: { slug: true, updatedAt: true, collection: { select: { slug: true } } },
70
+ });
71
+ res.json(items.map((i) => ({ path: `${i.collection.slug}/${i.slug}`, updatedAt: i.updatedAt })));
72
+ });
73
+
74
+ // --- Admin CRUD ---
75
+ collectionsRouter.use(requireAuth);
76
+
77
+ collectionsRouter.get("/", async (_req, res) => {
78
+ const collections = await prisma.collection.findMany({
79
+ orderBy: { createdAt: "asc" },
80
+ include: { _count: { select: { items: true } } },
81
+ });
82
+ res.json(collections);
83
+ });
84
+
85
+ collectionsRouter.post("/", requireRole("ADMIN", "EDITOR"), async (req, res) => {
86
+ const parsed = CreateCollectionInputSchema.safeParse(req.body);
87
+ if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
88
+ const clash = await prisma.collection.findUnique({ where: { slug: parsed.data.slug } });
89
+ if (clash) return res.status(409).json({ error: "A collection with this slug already exists" });
90
+ const collection = await prisma.collection.create({ data: parsed.data });
91
+ res.status(201).json(collection);
92
+ });
93
+
94
+ collectionsRouter.delete("/:id", requireRole("ADMIN"), async (req, res) => {
95
+ try {
96
+ await prisma.collection.delete({ where: { id: req.params.id } });
97
+ res.json({ ok: true });
98
+ } catch {
99
+ res.status(404).json({ error: "Collection not found" });
100
+ }
101
+ });
102
+
103
+ collectionsRouter.get("/:id/items", async (req, res) => {
104
+ const items = await prisma.collectionItem.findMany({
105
+ where: { collectionId: req.params.id },
106
+ orderBy: { createdAt: "desc" },
107
+ select: {
108
+ id: true,
109
+ slug: true,
110
+ title: true,
111
+ status: true,
112
+ publishedAt: true,
113
+ updatedAt: true,
114
+ locale: true,
115
+ translationOfId: true,
116
+ },
117
+ });
118
+ res.json(items);
119
+ });
120
+
121
+ collectionsRouter.post("/:id/items", requireRole("ADMIN", "EDITOR", "AUTHOR"), async (req, res) => {
122
+ const parsed = CreateCollectionItemInputSchema.safeParse(req.body);
123
+ if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
124
+ const clash = await prisma.collectionItem.findFirst({
125
+ where: { collectionId: req.params.id, slug: parsed.data.slug },
126
+ });
127
+ if (clash) return res.status(409).json({ error: "An item with this slug already exists in this collection" });
128
+ const item = await prisma.collectionItem.create({
129
+ data: {
130
+ collectionId: req.params.id,
131
+ title: parsed.data.title,
132
+ slug: parsed.data.slug,
133
+ content: createEmptyPage() as object,
134
+ createdById: req.user!.sub,
135
+ ...(parsed.data.locale ? { locale: parsed.data.locale } : {}),
136
+ },
137
+ });
138
+ res.status(201).json(item);
139
+ });
140
+
141
+ collectionsRouter.get("/items/:itemId", async (req, res) => {
142
+ const item = await prisma.collectionItem.findUnique({
143
+ where: { id: req.params.itemId },
144
+ include: { collection: { select: { name: true, slug: true } } },
145
+ });
146
+ if (!item) return res.status(404).json({ error: "Item not found" });
147
+ res.json(item);
148
+ });
149
+
150
+ collectionsRouter.put("/items/:itemId", requireRole("ADMIN", "EDITOR", "AUTHOR"), async (req, res) => {
151
+ const parsed = UpdateCollectionItemInputSchema.safeParse(req.body);
152
+ if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
153
+
154
+ const existingForOwnership = await prisma.collectionItem.findUnique({ where: { id: req.params.itemId } });
155
+ if (!existingForOwnership) return res.status(404).json({ error: "Item not found" });
156
+ if (!canModify(req.user!, existingForOwnership)) return res.status(403).json({ error: "Forbidden" });
157
+
158
+ const data: Record<string, unknown> = { ...parsed.data };
159
+ // First publish stamps publishedAt (the list sort key); republish keeps the original.
160
+ if (parsed.data.status === "PUBLISHED" && !existingForOwnership.publishedAt) {
161
+ data.publishedAt = new Date();
162
+ }
163
+ if (parsed.data.content !== undefined) {
164
+ data.searchText = extractSearchText(parsed.data.content as PageNode);
165
+ }
166
+
167
+ try {
168
+ const item = await prisma.collectionItem.update({ where: { id: req.params.itemId }, data: data as never });
169
+ if (parsed.data.status === "PUBLISHED" || parsed.data.content !== undefined) {
170
+ void triggerRevalidation(["cms", "cms-sitemap", `collection:${item.collectionId}`]);
171
+ }
172
+ res.json(item);
173
+ } catch {
174
+ res.status(404).json({ error: "Item not found" });
175
+ }
176
+ });
177
+
178
+ collectionsRouter.delete("/items/:itemId", requireRole("ADMIN", "EDITOR"), async (req, res) => {
179
+ try {
180
+ await prisma.collectionItem.delete({ where: { id: req.params.itemId } });
181
+ res.json({ ok: true });
182
+ } catch {
183
+ res.status(404).json({ error: "Item not found" });
184
+ }
185
+ });
@@ -0,0 +1,114 @@
1
+ import { Router } from "express";
2
+ import { z } from "zod";
3
+ import { prisma } from "../prisma.js";
4
+ import { requireAuth, requireRole } from "../middleware/auth.js";
5
+ import { rateLimit } from "../middleware/rateLimit.js";
6
+ import { env } from "../env.js";
7
+
8
+ export const formsRouter = Router();
9
+
10
+ const SubmitFormSchema = z.object({
11
+ formName: z.string().min(1).max(100),
12
+ pageSlug: z.string().max(200).default(""),
13
+ data: z.record(z.string(), z.string().max(5000)),
14
+ /** Honeypot — real users leave this empty; bots often fill every field. */
15
+ _hp: z.string().optional(),
16
+ });
17
+
18
+ const submitLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20, keyPrefix: "forms" });
19
+
20
+ async function notifyFormWebhook(payload: {
21
+ formName: string;
22
+ pageSlug: string;
23
+ data: Record<string, string>;
24
+ id: string;
25
+ createdAt: Date;
26
+ }) {
27
+ if (!env.formsWebhookUrl) return;
28
+ try {
29
+ await fetch(env.formsWebhookUrl, {
30
+ method: "POST",
31
+ headers: { "Content-Type": "application/json" },
32
+ body: JSON.stringify({ event: "form.submission", ...payload }),
33
+ });
34
+ } catch (err) {
35
+ console.warn("Form webhook delivery failed:", err);
36
+ }
37
+ }
38
+
39
+ // Public: anyone visiting the site can submit a Contact Form block.
40
+ formsRouter.post("/submit", submitLimiter, async (req, res) => {
41
+ const parsed = SubmitFormSchema.safeParse(req.body);
42
+ if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
43
+
44
+ if (parsed.data._hp) {
45
+ // Silently accept so bots don't retry with different payloads.
46
+ return res.status(201).json({ id: "ok" });
47
+ }
48
+
49
+ const { formName, pageSlug, data } = parsed.data;
50
+ const submission = await prisma.formSubmission.create({
51
+ data: { formName, pageSlug, data },
52
+ });
53
+
54
+ void notifyFormWebhook({
55
+ id: submission.id,
56
+ formName,
57
+ pageSlug,
58
+ data,
59
+ createdAt: submission.createdAt,
60
+ });
61
+
62
+ res.status(201).json({ id: submission.id });
63
+ });
64
+
65
+ // Admin: view submissions.
66
+ formsRouter.use(requireAuth, requireRole("ADMIN", "EDITOR"));
67
+
68
+ formsRouter.get("/submissions", async (req, res) => {
69
+ const formName = typeof req.query.formName === "string" ? req.query.formName : undefined;
70
+ const submissions = await prisma.formSubmission.findMany({
71
+ where: formName ? { formName } : undefined,
72
+ orderBy: { createdAt: "desc" },
73
+ take: 200,
74
+ });
75
+ res.json(submissions);
76
+ });
77
+
78
+ formsRouter.get("/submissions/export", async (req, res) => {
79
+ const formName = typeof req.query.formName === "string" ? req.query.formName : undefined;
80
+ const submissions = await prisma.formSubmission.findMany({
81
+ where: formName ? { formName } : undefined,
82
+ orderBy: { createdAt: "desc" },
83
+ take: 5000,
84
+ });
85
+
86
+ const fieldKeys = Array.from(new Set(submissions.flatMap((s) => Object.keys(s.data as Record<string, string>))));
87
+ const header = ["id", "formName", "pageSlug", "createdAt", ...fieldKeys];
88
+ const escape = (v: string) => `"${v.replace(/"/g, '""')}"`;
89
+ const rows = submissions.map((s) => {
90
+ const data = s.data as Record<string, string>;
91
+ return [
92
+ s.id,
93
+ s.formName,
94
+ s.pageSlug,
95
+ s.createdAt.toISOString(),
96
+ ...fieldKeys.map((k) => data[k] ?? ""),
97
+ ]
98
+ .map(escape)
99
+ .join(",");
100
+ });
101
+
102
+ res.setHeader("Content-Type", "text/csv; charset=utf-8");
103
+ res.setHeader("Content-Disposition", `attachment; filename="form-submissions${formName ? `-${formName}` : ""}.csv"`);
104
+ res.send([header.map(escape).join(","), ...rows].join("\n"));
105
+ });
106
+
107
+ formsRouter.delete("/submissions/:id", requireRole("ADMIN"), async (req, res) => {
108
+ try {
109
+ await prisma.formSubmission.delete({ where: { id: req.params.id } });
110
+ res.status(204).end();
111
+ } catch {
112
+ res.status(404).json({ error: "Not found" });
113
+ }
114
+ });
@@ -0,0 +1,43 @@
1
+ import { Router } from "express";
2
+ import { PageNodeSchema } from "@pgcms/shared";
3
+ import { z } from "zod";
4
+ import { prisma } from "../prisma.js";
5
+ import { requireAuth, requireRole } from "../middleware/auth.js";
6
+
7
+ export const globalSectionsRouter = Router();
8
+
9
+ const KINDS = new Set(["header", "footer", "goToTop"]);
10
+
11
+ const UpdateGlobalSectionSchema = z.object({
12
+ enabled: z.boolean().optional(),
13
+ content: PageNodeSchema.optional(),
14
+ });
15
+
16
+ // Public: both global sections in one response — the public site fetches this once
17
+ // per request alongside the page/theme.
18
+ globalSectionsRouter.get("/", async (_req, res) => {
19
+ const sections = await prisma.globalSection.findMany();
20
+ res.json(sections);
21
+ });
22
+
23
+ globalSectionsRouter.put("/:kind", requireAuth, requireRole("ADMIN", "EDITOR"), async (req, res) => {
24
+ const kind = req.params.kind;
25
+ if (!KINDS.has(kind)) return res.status(400).json({ error: "kind must be 'header' or 'footer'" });
26
+
27
+ const parsed = UpdateGlobalSectionSchema.safeParse(req.body);
28
+ if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
29
+
30
+ const section = await prisma.globalSection.upsert({
31
+ where: { kind },
32
+ update: {
33
+ ...(parsed.data.enabled !== undefined ? { enabled: parsed.data.enabled } : {}),
34
+ ...(parsed.data.content !== undefined ? { content: parsed.data.content as object } : {}),
35
+ },
36
+ create: {
37
+ kind,
38
+ enabled: parsed.data.enabled ?? false,
39
+ content: (parsed.data.content ?? { id: "root", type: "root", props: {}, style: {}, children: [] }) as object,
40
+ },
41
+ });
42
+ res.json(section);
43
+ });
@@ -0,0 +1,77 @@
1
+ import { Router } from "express";
2
+ import multer from "multer";
3
+ import { nanoid } from "nanoid";
4
+ import { prisma } from "../prisma.js";
5
+ import { requireAuth, requireRole } from "../middleware/auth.js";
6
+ import { uploadBuffer, deleteObject } from "../lib/storage/index.js";
7
+
8
+ export const mediaRouter = Router();
9
+
10
+ const upload = multer({
11
+ storage: multer.memoryStorage(),
12
+ limits: { fileSize: 100 * 1024 * 1024 }, // 100MB, enough for background videos
13
+ fileFilter: (_req, file, cb) => {
14
+ const allowed = /^image\/(png|jpeg|jpg|gif|webp|svg\+xml)$|^video\/(mp4|webm|ogg)$/;
15
+ if (allowed.test(file.mimetype)) cb(null, true);
16
+ else cb(new Error("Unsupported file type"));
17
+ },
18
+ });
19
+
20
+ mediaRouter.use(requireAuth);
21
+
22
+ mediaRouter.get("/", async (req, res) => {
23
+ const search = typeof req.query.search === "string" ? req.query.search.trim() : "";
24
+ const assets = await prisma.mediaAsset.findMany({
25
+ where: search
26
+ ? {
27
+ OR: [
28
+ { filename: { contains: search, mode: "insensitive" } },
29
+ { alt: { contains: search, mode: "insensitive" } },
30
+ { key: { contains: search, mode: "insensitive" } },
31
+ ],
32
+ }
33
+ : undefined,
34
+ orderBy: { createdAt: "desc" },
35
+ });
36
+ res.json(assets);
37
+ });
38
+
39
+ mediaRouter.post("/", requireRole("ADMIN", "EDITOR"), upload.single("file"), async (req, res) => {
40
+ if (!req.file) return res.status(400).json({ error: "No file uploaded" });
41
+
42
+ const ext = req.file.originalname.split(".").pop() ?? "bin";
43
+ const key = `${nanoid(16)}.${ext}`;
44
+ const url = await uploadBuffer(key, req.file.buffer, req.file.mimetype);
45
+
46
+ const asset = await prisma.mediaAsset.create({
47
+ data: {
48
+ key,
49
+ url,
50
+ mimeType: req.file.mimetype,
51
+ size: req.file.size,
52
+ filename: req.file.originalname,
53
+ },
54
+ });
55
+ res.status(201).json(asset);
56
+ });
57
+
58
+ // Metadata-only update — currently just alt text, the accessibility default used
59
+ // anywhere the asset renders without a per-usage alt.
60
+ mediaRouter.put("/:id", requireRole("ADMIN", "EDITOR"), async (req, res) => {
61
+ const alt = typeof req.body.alt === "string" ? req.body.alt : undefined;
62
+ if (alt === undefined) return res.status(400).json({ error: "alt (string) is required" });
63
+ try {
64
+ const asset = await prisma.mediaAsset.update({ where: { id: req.params.id }, data: { alt } });
65
+ res.json(asset);
66
+ } catch {
67
+ res.status(404).json({ error: "Not found" });
68
+ }
69
+ });
70
+
71
+ mediaRouter.delete("/:id", requireRole("ADMIN", "EDITOR"), async (req, res) => {
72
+ const asset = await prisma.mediaAsset.findUnique({ where: { id: req.params.id } });
73
+ if (!asset) return res.status(404).json({ error: "Not found" });
74
+ await deleteObject(asset.key);
75
+ await prisma.mediaAsset.delete({ where: { id: asset.id } });
76
+ res.status(204).end();
77
+ });