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,66 @@
1
+ import express from "express";
2
+ import cors from "cors";
3
+ import cookieParser from "cookie-parser";
4
+ import { env } from "./env.js";
5
+ import { securityHeaders } from "./middleware/securityHeaders.js";
6
+ import { authRouter } from "./routes/auth.routes.js";
7
+ import { pagesRouter } from "./routes/pages.routes.js";
8
+ import { mediaRouter } from "./routes/media.routes.js";
9
+ import { themeRouter } from "./routes/theme.routes.js";
10
+ import { usersRouter } from "./routes/users.routes.js";
11
+ import { formsRouter } from "./routes/forms.routes.js";
12
+ import { globalSectionsRouter } from "./routes/globalSections.routes.js";
13
+ import { blocksRouter } from "./routes/blocks.routes.js";
14
+ import { collectionsRouter } from "./routes/collections.routes.js";
15
+ import { searchRouter } from "./routes/search.routes.js";
16
+ import { redirectsRouter } from "./routes/redirects.routes.js";
17
+ import { auditRouter } from "./routes/audit.routes.js";
18
+ import { templatesRouter } from "./routes/templates.routes.js";
19
+ import { backupRouter } from "./routes/backup.routes.js";
20
+ import { auditLog } from "./middleware/auditLog.js";
21
+
22
+ const app = express();
23
+
24
+ app.set("trust proxy", 1);
25
+ app.use(securityHeaders);
26
+ app.use(cors({ origin: env.webOrigin, credentials: true }));
27
+ app.use(cookieParser());
28
+ app.use(express.json({ limit: "5mb" }));
29
+
30
+ app.use((req, res, next) => {
31
+ const start = Date.now();
32
+ res.on("finish", () => {
33
+ console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${Date.now() - start}ms`);
34
+ });
35
+ next();
36
+ });
37
+
38
+ app.get("/health", (_req, res) => res.json({ ok: true }));
39
+
40
+ // Registered before the routers, but it records on response-finish — by then the
41
+ // router's own requireAuth has attached req.user, so entries carry who did it.
42
+ app.use(auditLog);
43
+
44
+ app.use("/api/auth", authRouter);
45
+ app.use("/api/pages", pagesRouter);
46
+ app.use("/api/media", mediaRouter);
47
+ app.use("/api/theme", themeRouter);
48
+ app.use("/api/users", usersRouter);
49
+ app.use("/api/forms", formsRouter);
50
+ app.use("/api/global-sections", globalSectionsRouter);
51
+ app.use("/api/blocks", blocksRouter);
52
+ app.use("/api/collections", collectionsRouter);
53
+ app.use("/api/search", searchRouter);
54
+ app.use("/api/redirects", redirectsRouter);
55
+ app.use("/api/audit", auditRouter);
56
+ app.use("/api/templates", templatesRouter);
57
+ app.use("/api/backup", backupRouter);
58
+
59
+ app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
60
+ console.error(err);
61
+ res.status(err.status ?? 500).json({ error: err.message ?? "Internal server error" });
62
+ });
63
+
64
+ app.listen(env.port, () => {
65
+ console.log(`pg-cms API listening on http://localhost:${env.port}`);
66
+ });
@@ -0,0 +1,28 @@
1
+ /** Ask the Next.js app to bust its ISR cache after content changes. No-op when
2
+ * REVALIDATE_URL / REVALIDATE_SECRET are unset (typical in local dev). */
3
+ export async function triggerRevalidation(tags: string[]): Promise<void> {
4
+ const url = process.env.REVALIDATE_URL;
5
+ const secret = process.env.REVALIDATE_SECRET;
6
+ if (!url || !secret || tags.length === 0) return;
7
+
8
+ try {
9
+ const res = await fetch(`${url.replace(/\/$/, "")}/api/revalidate`, {
10
+ method: "POST",
11
+ headers: {
12
+ "Content-Type": "application/json",
13
+ Authorization: `Bearer ${secret}`,
14
+ },
15
+ body: JSON.stringify({ tags }),
16
+ });
17
+ if (!res.ok) {
18
+ console.warn(`Revalidation failed (${res.status}):`, await res.text());
19
+ }
20
+ } catch (err) {
21
+ console.warn("Revalidation request failed:", err);
22
+ }
23
+ }
24
+
25
+ export function pageCacheTags(slug: string): string[] {
26
+ const normalized = slug || "home";
27
+ return ["cms", "cms-pages", `page:${normalized}`, "cms-sitemap"];
28
+ }
@@ -0,0 +1,25 @@
1
+ import { BlobServiceClient } from "@azure/storage-blob";
2
+ import { env } from "../../env.js";
3
+ import type { StorageProvider } from "./types.js";
4
+
5
+ export function createAzureStorage(): StorageProvider {
6
+ const config = env.azure!;
7
+ const serviceClient = BlobServiceClient.fromConnectionString(config.connectionString);
8
+ const containerClient = serviceClient.getContainerClient(config.container);
9
+
10
+ function publicUrlFor(key: string) {
11
+ if (config.publicUrl) return `${config.publicUrl}/${key}`;
12
+ return containerClient.getBlockBlobClient(key).url;
13
+ }
14
+
15
+ return {
16
+ async upload(key, buffer, contentType) {
17
+ const blockBlobClient = containerClient.getBlockBlobClient(key);
18
+ await blockBlobClient.uploadData(buffer, { blobHTTPHeaders: { blobContentType: contentType } });
19
+ return publicUrlFor(key);
20
+ },
21
+ async delete(key) {
22
+ await containerClient.getBlockBlobClient(key).deleteIfExists();
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,42 @@
1
+ import { v2 as cloudinary } from "cloudinary";
2
+ import { Readable } from "node:stream";
3
+ import { env } from "../../env.js";
4
+ import type { StorageProvider } from "./types.js";
5
+
6
+ export function createCloudinaryStorage(): StorageProvider {
7
+ const config = env.cloudinary!;
8
+ cloudinary.config({
9
+ cloud_name: config.cloudName,
10
+ api_key: config.apiKey,
11
+ api_secret: config.apiSecret,
12
+ });
13
+
14
+ // Cloudinary's public_id carries no file extension — strip it so a later delete()
15
+ // targets the exact asset this upload created, not a literal "name.jpg" that doesn't
16
+ // exist as a public_id.
17
+ function publicId(key: string) {
18
+ return `${config.folder}/${key.replace(/\.[^./]+$/, "")}`;
19
+ }
20
+
21
+ return {
22
+ upload(key, buffer, contentType) {
23
+ return new Promise<string>((resolve, reject) => {
24
+ const stream = cloudinary.uploader.upload_stream(
25
+ {
26
+ public_id: publicId(key),
27
+ resource_type: contentType.startsWith("video/") ? "video" : "image",
28
+ overwrite: true,
29
+ },
30
+ (err, result) => {
31
+ if (err || !result) return reject(err ?? new Error("Cloudinary upload failed"));
32
+ resolve(result.secure_url);
33
+ }
34
+ );
35
+ Readable.from(buffer).pipe(stream);
36
+ });
37
+ },
38
+ async delete(key) {
39
+ await cloudinary.uploader.destroy(publicId(key));
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,25 @@
1
+ import { env } from "../../env.js";
2
+ import type { StorageProvider } from "./types.js";
3
+ import { createS3Storage } from "./s3.js";
4
+ import { createAzureStorage } from "./azure.js";
5
+ import { createCloudinaryStorage } from "./cloudinary.js";
6
+
7
+ let cached: StorageProvider | null = null;
8
+
9
+ function build(): StorageProvider {
10
+ if (env.storageProvider === "azure") return createAzureStorage();
11
+ if (env.storageProvider === "cloudinary") return createCloudinaryStorage();
12
+ return createS3Storage();
13
+ }
14
+
15
+ function getStorage(): StorageProvider {
16
+ return (cached ??= build());
17
+ }
18
+
19
+ export async function uploadBuffer(key: string, buffer: Buffer, contentType: string): Promise<string> {
20
+ return getStorage().upload(key, buffer, contentType);
21
+ }
22
+
23
+ export async function deleteObject(key: string): Promise<void> {
24
+ return getStorage().delete(key);
25
+ }
@@ -0,0 +1,39 @@
1
+ import { S3Client, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
2
+ import { env } from "../../env.js";
3
+ import type { StorageProvider } from "./types.js";
4
+
5
+ /** Works for real AWS S3 and any S3-compatible endpoint (MinIO in dev, DigitalOcean
6
+ * Spaces, Cloudflare R2, etc.) — same client either way, just a different endpoint. */
7
+ export function createS3Storage(): StorageProvider {
8
+ const config = env.s3!;
9
+ const client = new S3Client({
10
+ endpoint: config.endpoint,
11
+ region: config.region,
12
+ forcePathStyle: config.forcePathStyle,
13
+ credentials: {
14
+ accessKeyId: config.accessKeyId,
15
+ secretAccessKey: config.secretAccessKey,
16
+ },
17
+ });
18
+
19
+ function publicUrlFor(key: string) {
20
+ return `${config.publicUrl}/${key}`;
21
+ }
22
+
23
+ return {
24
+ async upload(key, buffer, contentType) {
25
+ await client.send(
26
+ new PutObjectCommand({
27
+ Bucket: config.bucket,
28
+ Key: key,
29
+ Body: buffer,
30
+ ContentType: contentType,
31
+ })
32
+ );
33
+ return publicUrlFor(key);
34
+ },
35
+ async delete(key) {
36
+ await client.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: key }));
37
+ },
38
+ };
39
+ }
@@ -0,0 +1,8 @@
1
+ /** Every storage backend (S3-compatible, Azure Blob, Cloudinary) implements this same
2
+ * shape, so media.routes.ts never has to know which one is active. */
3
+ export interface StorageProvider {
4
+ /** Uploads a buffer under `key` and returns the public URL the browser can load it
5
+ * from directly (media assets are never served through a signed-URL proxy today). */
6
+ upload(key: string, buffer: Buffer, contentType: string): Promise<string>;
7
+ delete(key: string): Promise<void>;
8
+ }
@@ -0,0 +1,32 @@
1
+ import type { NextFunction, Request, Response } from "express";
2
+ import { prisma } from "../prisma.js";
3
+
4
+ // Paths whose mutations aren't editorial actions worth auditing: public form
5
+ // submissions (spam volume) and the auth endpoints' own bodies (logins ARE logged,
6
+ // via the response hook below, but never their credentials — we only record the URL).
7
+ const SKIP_PREFIXES = ["/api/forms/submit"];
8
+
9
+ /** Records every successful mutating API call (who, what URL, when) after the
10
+ * response finishes. Middleware-based so new routes are audited automatically instead
11
+ * of relying on every future handler remembering to log. Failures to write the log
12
+ * never affect the request itself. */
13
+ export function auditLog(req: Request, res: Response, next: NextFunction) {
14
+ const mutating = req.method !== "GET" && req.method !== "HEAD" && req.method !== "OPTIONS";
15
+ if (!mutating || SKIP_PREFIXES.some((p) => req.path.startsWith(p))) return next();
16
+
17
+ res.on("finish", () => {
18
+ if (res.statusCode >= 400) return; // only record actions that actually happened
19
+ void prisma.auditLog
20
+ .create({
21
+ data: {
22
+ userId: req.user?.sub ?? null,
23
+ userEmail: req.user?.email ?? null,
24
+ action: `${req.method} ${req.path.split("/").slice(0, 4).join("/")}`,
25
+ entityPath: req.originalUrl.slice(0, 500),
26
+ status: res.statusCode,
27
+ },
28
+ })
29
+ .catch((err) => console.warn("audit log write failed:", err));
30
+ });
31
+ next();
32
+ }
@@ -0,0 +1,53 @@
1
+ import type { NextFunction, Request, Response } from "express";
2
+ import jwt from "jsonwebtoken";
3
+ import type { UserRole } from "@pgcms/shared";
4
+ import { env } from "../env.js";
5
+
6
+ export type AuthTokenPayload = {
7
+ sub: string;
8
+ email: string;
9
+ role: UserRole;
10
+ };
11
+
12
+ declare global {
13
+ // eslint-disable-next-line @typescript-eslint/no-namespace
14
+ namespace Express {
15
+ interface Request {
16
+ user?: AuthTokenPayload;
17
+ }
18
+ }
19
+ }
20
+
21
+ export function signToken(payload: AuthTokenPayload): string {
22
+ return jwt.sign(payload, env.jwtSecret, { expiresIn: env.jwtExpiresIn as any });
23
+ }
24
+
25
+ export function requireAuth(req: Request, res: Response, next: NextFunction) {
26
+ const token = req.cookies?.[env.cookieName];
27
+ if (!token) return res.status(401).json({ error: "Not authenticated" });
28
+ try {
29
+ const payload = jwt.verify(token, env.jwtSecret) as AuthTokenPayload;
30
+ req.user = payload;
31
+ next();
32
+ } catch {
33
+ return res.status(401).json({ error: "Invalid or expired session" });
34
+ }
35
+ }
36
+
37
+ export function requireRole(...roles: UserRole[]) {
38
+ return (req: Request, res: Response, next: NextFunction) => {
39
+ if (!req.user) return res.status(401).json({ error: "Not authenticated" });
40
+ if (!roles.includes(req.user.role)) return res.status(403).json({ error: "Forbidden" });
41
+ next();
42
+ };
43
+ }
44
+
45
+ /** ADMIN/EDITOR can touch any resource, exactly as before AUTHOR existed. An AUTHOR can
46
+ * only touch a resource they created — used inline in mutation routes (after the
47
+ * resource is fetched) rather than as router middleware, since ownership can't be
48
+ * checked before the row exists. `createdById` is nullable (pre-AUTHOR rows, or rows an
49
+ * ADMIN created on someone's behalf), and null never matches an AUTHOR's own id. */
50
+ export function canModify(user: AuthTokenPayload, resource: { createdById?: string | null }): boolean {
51
+ if (user.role !== "AUTHOR") return true;
52
+ return resource.createdById === user.sub;
53
+ }
@@ -0,0 +1,31 @@
1
+ import type { NextFunction, Request, Response } from "express";
2
+
3
+ type Bucket = { count: number; resetAt: number };
4
+
5
+ const buckets = new Map<string, Bucket>();
6
+
7
+ /** Lightweight in-memory rate limiter — good enough for single-instance deploys.
8
+ * Resets automatically after `windowMs`. */
9
+ export function rateLimit(options: { windowMs: number; max: number; keyPrefix?: string }) {
10
+ const { windowMs, max, keyPrefix = "" } = options;
11
+
12
+ return (req: Request, res: Response, next: NextFunction) => {
13
+ const ip = req.ip || req.socket.remoteAddress || "unknown";
14
+ const key = `${keyPrefix}:${ip}`;
15
+ const now = Date.now();
16
+ let bucket = buckets.get(key);
17
+
18
+ if (!bucket || now >= bucket.resetAt) {
19
+ bucket = { count: 0, resetAt: now + windowMs };
20
+ buckets.set(key, bucket);
21
+ }
22
+
23
+ bucket.count += 1;
24
+ if (bucket.count > max) {
25
+ res.setHeader("Retry-After", String(Math.ceil((bucket.resetAt - now) / 1000)));
26
+ return res.status(429).json({ error: "Too many requests — please try again later." });
27
+ }
28
+
29
+ next();
30
+ };
31
+ }
@@ -0,0 +1,10 @@
1
+ import type { NextFunction, Request, Response } from "express";
2
+
3
+ /** Baseline security headers without pulling in helmet (keeps deps lean). */
4
+ export function securityHeaders(_req: Request, res: Response, next: NextFunction) {
5
+ res.setHeader("X-Content-Type-Options", "nosniff");
6
+ res.setHeader("X-Frame-Options", "SAMEORIGIN");
7
+ res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
8
+ res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
9
+ next();
10
+ }
@@ -0,0 +1,3 @@
1
+ import { PrismaClient } from "@prisma/client";
2
+
3
+ export const prisma = new PrismaClient();
@@ -0,0 +1,26 @@
1
+ import { Router } from "express";
2
+ import { prisma } from "../prisma.js";
3
+ import { requireAuth, requireRole } from "../middleware/auth.js";
4
+
5
+ export const auditRouter = Router();
6
+
7
+ // Reading the audit trail is an admin-only capability.
8
+ auditRouter.use(requireAuth, requireRole("ADMIN"));
9
+
10
+ auditRouter.get("/", async (req, res) => {
11
+ const search = typeof req.query.search === "string" ? req.query.search.trim() : "";
12
+ const entries = await prisma.auditLog.findMany({
13
+ where: search
14
+ ? {
15
+ OR: [
16
+ { userEmail: { contains: search, mode: "insensitive" } },
17
+ { entityPath: { contains: search, mode: "insensitive" } },
18
+ { action: { contains: search, mode: "insensitive" } },
19
+ ],
20
+ }
21
+ : undefined,
22
+ orderBy: { createdAt: "desc" },
23
+ take: 200,
24
+ });
25
+ res.json(entries);
26
+ });
@@ -0,0 +1,46 @@
1
+ import { Router } from "express";
2
+ import bcrypt from "bcryptjs";
3
+ import { LoginInputSchema } from "@pgcms/shared";
4
+ import { prisma } from "../prisma.js";
5
+ import { signToken, requireAuth } from "../middleware/auth.js";
6
+ import { rateLimit } from "../middleware/rateLimit.js";
7
+ import { env } from "../env.js";
8
+
9
+ export const authRouter = Router();
10
+
11
+ const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 15, keyPrefix: "login" });
12
+
13
+ const cookieOptions = {
14
+ httpOnly: true,
15
+ sameSite: "lax" as const,
16
+ secure: process.env.NODE_ENV === "production",
17
+ maxAge: 7 * 24 * 60 * 60 * 1000,
18
+ path: "/",
19
+ };
20
+
21
+ authRouter.post("/login", loginLimiter, async (req, res) => {
22
+ const parsed = LoginInputSchema.safeParse(req.body);
23
+ if (!parsed.success) return res.status(400).json({ error: "Invalid input" });
24
+
25
+ const { email, password } = parsed.data;
26
+ const user = await prisma.user.findUnique({ where: { email } });
27
+ if (!user) return res.status(401).json({ error: "Invalid email or password" });
28
+
29
+ const valid = await bcrypt.compare(password, user.passwordHash);
30
+ if (!valid) return res.status(401).json({ error: "Invalid email or password" });
31
+
32
+ const token = signToken({ sub: user.id, email: user.email, role: user.role });
33
+ res.cookie(env.cookieName, token, cookieOptions);
34
+ res.json({ id: user.id, email: user.email, role: user.role });
35
+ });
36
+
37
+ authRouter.post("/logout", (_req, res) => {
38
+ res.clearCookie(env.cookieName, { ...cookieOptions, maxAge: undefined });
39
+ res.status(204).end();
40
+ });
41
+
42
+ authRouter.get("/me", requireAuth, async (req, res) => {
43
+ const user = await prisma.user.findUnique({ where: { id: req.user!.sub } });
44
+ if (!user) return res.status(404).json({ error: "Not found" });
45
+ res.json({ id: user.id, email: user.email, role: user.role });
46
+ });
@@ -0,0 +1,216 @@
1
+ import { Router } from "express";
2
+ import { z } from "zod";
3
+ import { nanoid } from "nanoid";
4
+ import { cloneWithNewIds, extractSearchText, PageNodeSchema, type PageNode } from "@pgcms/shared";
5
+ import { prisma } from "../prisma.js";
6
+ import { requireAuth, requireRole } from "../middleware/auth.js";
7
+
8
+ export const backupRouter = Router();
9
+
10
+ // Whole-site backup is the most destructive-adjacent feature in the admin — restore is
11
+ // additive-only (never overwrites/deletes), but export alone already reveals every
12
+ // page's content plus theme/nav/redirect configuration, so both directions are
13
+ // ADMIN-only rather than the usual ADMIN/EDITOR pairing.
14
+ backupRouter.use(requireAuth, requireRole("ADMIN"));
15
+
16
+ backupRouter.get("/export", async (_req, res) => {
17
+ const [theme, navItems, pages, collections, globalSections, redirects, savedBlocks, templates] =
18
+ await Promise.all([
19
+ prisma.siteTheme.findFirst(),
20
+ prisma.navItem.findMany(),
21
+ prisma.page.findMany(),
22
+ prisma.collection.findMany({ include: { items: true } }),
23
+ prisma.globalSection.findMany(),
24
+ prisma.redirect.findMany(),
25
+ prisma.savedBlock.findMany(),
26
+ prisma.pageTemplate.findMany(),
27
+ ]);
28
+
29
+ res.setHeader("Content-Disposition", `attachment; filename="pg-cms-backup-${Date.now()}.json"`);
30
+ res.json({
31
+ exportedAt: new Date().toISOString(),
32
+ theme,
33
+ navItems,
34
+ pages,
35
+ collections,
36
+ globalSections,
37
+ redirects,
38
+ savedBlocks,
39
+ templates,
40
+ });
41
+ });
42
+
43
+ const BackupSchema = z.object({
44
+ theme: z.record(z.string(), z.unknown()).nullable().optional(),
45
+ navItems: z.array(z.record(z.string(), z.unknown())).optional(),
46
+ pages: z.array(z.record(z.string(), z.unknown())).optional(),
47
+ collections: z.array(z.record(z.string(), z.unknown())).optional(),
48
+ globalSections: z.array(z.record(z.string(), z.unknown())).optional(),
49
+ redirects: z.array(z.record(z.string(), z.unknown())).optional(),
50
+ savedBlocks: z.array(z.record(z.string(), z.unknown())).optional(),
51
+ templates: z.array(z.record(z.string(), z.unknown())).optional(),
52
+ });
53
+
54
+ type Counts = Record<string, number>;
55
+
56
+ /** Restores a previously exported bundle additively: an entity is created only if
57
+ * nothing with its natural key already exists, and is otherwise skipped untouched —
58
+ * never overwritten, never deleted. Mirrors the "never destroy user content" rule the
59
+ * builder already follows for column/zone/grid-count shrinks. */
60
+ backupRouter.post("/import", async (req, res) => {
61
+ const parsed = BackupSchema.safeParse(req.body);
62
+ if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
63
+ const data = parsed.data;
64
+ const created: Counts = {};
65
+ const skipped: Counts = {};
66
+ const bump = (bucket: Counts, key: string) => (bucket[key] = (bucket[key] ?? 0) + 1);
67
+
68
+ if (data.theme && !(await prisma.siteTheme.findFirst())) {
69
+ const { id: _id, ...rest } = data.theme as Record<string, unknown>;
70
+ await prisma.siteTheme.create({ data: rest as any });
71
+ bump(created, "theme");
72
+ } else if (data.theme) {
73
+ bump(skipped, "theme");
74
+ }
75
+
76
+ for (const raw of data.navItems ?? []) {
77
+ const label = String(raw.label ?? "");
78
+ const href = String(raw.href ?? "");
79
+ const exists = await prisma.navItem.findFirst({ where: { label, href } });
80
+ if (exists) {
81
+ bump(skipped, "navItems");
82
+ continue;
83
+ }
84
+ await prisma.navItem.create({
85
+ data: { label, href, order: Number(raw.order) || 0 },
86
+ });
87
+ bump(created, "navItems");
88
+ }
89
+
90
+ for (const raw of data.pages ?? []) {
91
+ const slug = String(raw.slug ?? "");
92
+ if (await prisma.page.findUnique({ where: { slug } })) {
93
+ bump(skipped, "pages");
94
+ continue;
95
+ }
96
+ const parsedContent = PageNodeSchema.safeParse(raw.content);
97
+ if (!parsedContent.success) {
98
+ bump(skipped, "pages");
99
+ continue;
100
+ }
101
+ const content: PageNode = cloneWithNewIds(parsedContent.data);
102
+ await prisma.page.create({
103
+ data: {
104
+ title: String(raw.title ?? "Untitled"),
105
+ slug,
106
+ status: "DRAFT",
107
+ content: content as any,
108
+ searchText: extractSearchText(content),
109
+ previewToken: nanoid(24),
110
+ locale: typeof raw.locale === "string" ? raw.locale : "en",
111
+ createdById: req.user!.sub,
112
+ },
113
+ });
114
+ bump(created, "pages");
115
+ }
116
+
117
+ for (const rawCollection of data.collections ?? []) {
118
+ const slug = String(rawCollection.slug ?? "");
119
+ if (await prisma.collection.findUnique({ where: { slug } })) {
120
+ bump(skipped, "collections");
121
+ continue;
122
+ }
123
+ const items = Array.isArray(rawCollection.items) ? rawCollection.items : [];
124
+ const collection = await prisma.collection.create({
125
+ data: { name: String(rawCollection.name ?? slug), slug },
126
+ });
127
+ bump(created, "collections");
128
+ for (const rawItem of items as Record<string, unknown>[]) {
129
+ const parsedContent = PageNodeSchema.safeParse(rawItem.content);
130
+ if (!parsedContent.success) continue;
131
+ const content: PageNode = cloneWithNewIds(parsedContent.data);
132
+ await prisma.collectionItem.create({
133
+ data: {
134
+ collectionId: collection.id,
135
+ title: String(rawItem.title ?? "Untitled"),
136
+ slug: String(rawItem.slug ?? nanoid(8)),
137
+ content: content as any,
138
+ searchText: extractSearchText(content),
139
+ locale: typeof rawItem.locale === "string" ? rawItem.locale : "en",
140
+ createdById: req.user!.sub,
141
+ },
142
+ });
143
+ bump(created, "collectionItems");
144
+ }
145
+ }
146
+
147
+ for (const raw of data.globalSections ?? []) {
148
+ const kind = String(raw.kind ?? "");
149
+ if (await prisma.globalSection.findUnique({ where: { kind } })) {
150
+ bump(skipped, "globalSections");
151
+ continue;
152
+ }
153
+ const parsedContent = PageNodeSchema.safeParse(raw.content);
154
+ if (!parsedContent.success) {
155
+ bump(skipped, "globalSections");
156
+ continue;
157
+ }
158
+ await prisma.globalSection.create({
159
+ data: { kind, enabled: Boolean(raw.enabled), content: parsedContent.data as any },
160
+ });
161
+ bump(created, "globalSections");
162
+ }
163
+
164
+ for (const raw of data.redirects ?? []) {
165
+ const fromPath = String(raw.fromPath ?? "");
166
+ if (await prisma.redirect.findUnique({ where: { fromPath } })) {
167
+ bump(skipped, "redirects");
168
+ continue;
169
+ }
170
+ await prisma.redirect.create({
171
+ data: { fromPath, toPath: String(raw.toPath ?? "/"), permanent: raw.permanent !== false },
172
+ });
173
+ bump(created, "redirects");
174
+ }
175
+
176
+ for (const raw of data.savedBlocks ?? []) {
177
+ const name = String(raw.name ?? "");
178
+ if (await prisma.savedBlock.findFirst({ where: { name } })) {
179
+ bump(skipped, "savedBlocks");
180
+ continue;
181
+ }
182
+ const parsedContent = PageNodeSchema.safeParse(raw.content);
183
+ if (!parsedContent.success) {
184
+ bump(skipped, "savedBlocks");
185
+ continue;
186
+ }
187
+ await prisma.savedBlock.create({
188
+ data: { name, content: parsedContent.data as any, createdById: req.user!.sub },
189
+ });
190
+ bump(created, "savedBlocks");
191
+ }
192
+
193
+ for (const raw of data.templates ?? []) {
194
+ const name = String(raw.name ?? "");
195
+ if (await prisma.pageTemplate.findFirst({ where: { name } })) {
196
+ bump(skipped, "templates");
197
+ continue;
198
+ }
199
+ const parsedContent = PageNodeSchema.safeParse(raw.content);
200
+ if (!parsedContent.success) {
201
+ bump(skipped, "templates");
202
+ continue;
203
+ }
204
+ await prisma.pageTemplate.create({
205
+ data: {
206
+ name,
207
+ category: typeof raw.category === "string" ? raw.category : "General",
208
+ thumbnail: typeof raw.thumbnail === "string" ? raw.thumbnail : null,
209
+ content: parsedContent.data as any,
210
+ },
211
+ });
212
+ bump(created, "templates");
213
+ }
214
+
215
+ res.json({ created, skipped });
216
+ });