simple-module-pagebuilder 0.0.1__py3-none-any.whl

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 (189) hide show
  1. pagebuilder/__init__.py +0 -0
  2. pagebuilder/components/DiffSummary.tsx +107 -0
  3. pagebuilder/components/ScheduledBadge.tsx +34 -0
  4. pagebuilder/components/StatusBadge.tsx +21 -0
  5. pagebuilder/components/blockRegistry.test.ts +56 -0
  6. pagebuilder/components/blockRegistry.ts +80 -0
  7. pagebuilder/components/blocks/Button.tsx +142 -0
  8. pagebuilder/components/blocks/Columns.tsx +64 -0
  9. pagebuilder/components/blocks/Heading.tsx +92 -0
  10. pagebuilder/components/blocks/Image.tsx +125 -0
  11. pagebuilder/components/blocks/MediaPicker.tsx +178 -0
  12. pagebuilder/components/blocks/Spacer.tsx +31 -0
  13. pagebuilder/components/blocks/Text.tsx +80 -0
  14. pagebuilder/components/editor/PageEditorToolbar.tsx +169 -0
  15. pagebuilder/components/editor/RevisionHistoryPanel.tsx +95 -0
  16. pagebuilder/components/editor/SchedulePanel.tsx +85 -0
  17. pagebuilder/components/editor/SeoSettingsPanel.tsx +119 -0
  18. pagebuilder/components/layoutPuckConfig.tsx +109 -0
  19. pagebuilder/components/media/MediaFilters.tsx +83 -0
  20. pagebuilder/components/media/MediaFolderSidebar.tsx +97 -0
  21. pagebuilder/components/media/MediaGrid.tsx +76 -0
  22. pagebuilder/components/media/MediaHeader.tsx +37 -0
  23. pagebuilder/components/media/MediaUploadQueue.tsx +103 -0
  24. pagebuilder/components/media/types.ts +20 -0
  25. pagebuilder/components/migrateContent.test.ts +146 -0
  26. pagebuilder/components/migrateContent.ts +135 -0
  27. pagebuilder/components/puckConfig.test.tsx +118 -0
  28. pagebuilder/components/puckConfig.tsx +121 -0
  29. pagebuilder/components/widgetCatalog.test.tsx +123 -0
  30. pagebuilder/components/widgetCatalog.ts +208 -0
  31. pagebuilder/components/widgets/_internal/accent-text.tsx +60 -0
  32. pagebuilder/components/widgets/_internal/disclosure-chevron.tsx +14 -0
  33. pagebuilder/components/widgets/_internal/eyebrow-split-section.tsx +71 -0
  34. pagebuilder/components/widgets/_internal/link-arrow.tsx +23 -0
  35. pagebuilder/components/widgets/_internal/rich-text.tsx +243 -0
  36. pagebuilder/components/widgets/_internal/solid-icons.tsx +73 -0
  37. pagebuilder/components/widgets/_shared/background-media.tsx +74 -0
  38. pagebuilder/components/widgets/_shared/content-media.tsx +43 -0
  39. pagebuilder/components/widgets/_shared/cta-button.tsx +69 -0
  40. pagebuilder/components/widgets/_shared/empty-placeholder.tsx +15 -0
  41. pagebuilder/components/widgets/_shared/eyebrow-text.tsx +27 -0
  42. pagebuilder/components/widgets/_shared/heading.tsx +88 -0
  43. pagebuilder/components/widgets/_shared/index.ts +39 -0
  44. pagebuilder/components/widgets/_shared/mask.ts +54 -0
  45. pagebuilder/components/widgets/_shared/section.tsx +60 -0
  46. pagebuilder/components/widgets/_shared/social-icon.tsx +71 -0
  47. pagebuilder/components/widgets/_shared/text-link.tsx +47 -0
  48. pagebuilder/components/widgets/accordion-widget.tsx +64 -0
  49. pagebuilder/components/widgets/alert-widget.tsx +47 -0
  50. pagebuilder/components/widgets/app-store-badges-widget.tsx +91 -0
  51. pagebuilder/components/widgets/article-cards-render.tsx +132 -0
  52. pagebuilder/components/widgets/article-cards-widget.tsx +72 -0
  53. pagebuilder/components/widgets/call-to-action-render.tsx +285 -0
  54. pagebuilder/components/widgets/call-to-action-styles.ts +28 -0
  55. pagebuilder/components/widgets/call-to-action-widget.tsx +83 -0
  56. pagebuilder/components/widgets/carousel-widget.tsx +97 -0
  57. pagebuilder/components/widgets/centered-hero-widget.tsx +54 -0
  58. pagebuilder/components/widgets/colored-action-list-widget.tsx +117 -0
  59. pagebuilder/components/widgets/column-widget.tsx +63 -0
  60. pagebuilder/components/widgets/contact-cards-widget.tsx +172 -0
  61. pagebuilder/components/widgets/contact-form-render.tsx +293 -0
  62. pagebuilder/components/widgets/contact-form-widget.tsx +69 -0
  63. pagebuilder/components/widgets/container-widget.tsx +93 -0
  64. pagebuilder/components/widgets/definition-list-widget.tsx +144 -0
  65. pagebuilder/components/widgets/divider-widget.tsx +56 -0
  66. pagebuilder/components/widgets/eyebrow-section-widget.tsx +56 -0
  67. pagebuilder/components/widgets/faq-render.tsx +268 -0
  68. pagebuilder/components/widgets/faq-widget.tsx +64 -0
  69. pagebuilder/components/widgets/feature-card-widget.tsx +98 -0
  70. pagebuilder/components/widgets/feature-cards-render.tsx +258 -0
  71. pagebuilder/components/widgets/feature-cards-styles.ts +107 -0
  72. pagebuilder/components/widgets/feature-cards-widget.tsx +133 -0
  73. pagebuilder/components/widgets/feature-grid-widget.tsx +118 -0
  74. pagebuilder/components/widgets/feature-showcase-widget.tsx +118 -0
  75. pagebuilder/components/widgets/gallery-widget.tsx +139 -0
  76. pagebuilder/components/widgets/grid-widget.tsx +59 -0
  77. pagebuilder/components/widgets/hero-widget.tsx +292 -0
  78. pagebuilder/components/widgets/html-widget.tsx +42 -0
  79. pagebuilder/components/widgets/iframe-widget.tsx +44 -0
  80. pagebuilder/components/widgets/leaderboard-widget.tsx +203 -0
  81. pagebuilder/components/widgets/list-widget.tsx +56 -0
  82. pagebuilder/components/widgets/logo-cloud-widget.tsx +127 -0
  83. pagebuilder/components/widgets/markdown-widget.tsx +148 -0
  84. pagebuilder/components/widgets/media-object-layout.ts +156 -0
  85. pagebuilder/components/widgets/media-object-render.tsx +284 -0
  86. pagebuilder/components/widgets/media-object-text.tsx +202 -0
  87. pagebuilder/components/widgets/media-object-widget.tsx +176 -0
  88. pagebuilder/components/widgets/newsletter-widget.tsx +217 -0
  89. pagebuilder/components/widgets/page-header-widget.tsx +118 -0
  90. pagebuilder/components/widgets/prose-section-widget.tsx +99 -0
  91. pagebuilder/components/widgets/quote-widget.tsx +51 -0
  92. pagebuilder/components/widgets/row-widget.tsx +74 -0
  93. pagebuilder/components/widgets/section-intro-widget.tsx +143 -0
  94. pagebuilder/components/widgets/signup-banner-widget.tsx +50 -0
  95. pagebuilder/components/widgets/simple-action-list-widget.tsx +91 -0
  96. pagebuilder/components/widgets/site-footer-widget.tsx +97 -0
  97. pagebuilder/components/widgets/site-header-nav.tsx +153 -0
  98. pagebuilder/components/widgets/site-header-widget.tsx +182 -0
  99. pagebuilder/components/widgets/social-banner-widget.tsx +83 -0
  100. pagebuilder/components/widgets/stats-widget.tsx +200 -0
  101. pagebuilder/components/widgets/steps-widget.tsx +211 -0
  102. pagebuilder/components/widgets/story-cards-widget.tsx +182 -0
  103. pagebuilder/components/widgets/table-widget.tsx +86 -0
  104. pagebuilder/components/widgets/tabs-widget.tsx +95 -0
  105. pagebuilder/components/widgets/tags-widget.tsx +85 -0
  106. pagebuilder/components/widgets/testimonial-widget.tsx +102 -0
  107. pagebuilder/components/widgets/timeline-layout.ts +73 -0
  108. pagebuilder/components/widgets/timeline-render.tsx +294 -0
  109. pagebuilder/components/widgets/timeline-widget.tsx +106 -0
  110. pagebuilder/components/widgets/under-construction-widget.tsx +40 -0
  111. pagebuilder/components/widgets/video-widget.tsx +103 -0
  112. pagebuilder/components/widgets/welcome-widget.tsx +38 -0
  113. pagebuilder/contracts/__init__.py +57 -0
  114. pagebuilder/contracts/events.py +25 -0
  115. pagebuilder/contracts/schemas.py +239 -0
  116. pagebuilder/conversion/blocks-to-html.test.ts +105 -0
  117. pagebuilder/conversion/blocks-to-html.ts +102 -0
  118. pagebuilder/conversion/index.ts +2 -0
  119. pagebuilder/conversion/types.ts +5 -0
  120. pagebuilder/deps.py +26 -0
  121. pagebuilder/diff.py +129 -0
  122. pagebuilder/endpoints/__init__.py +0 -0
  123. pagebuilder/endpoints/api/__init__.py +21 -0
  124. pagebuilder/endpoints/api/_deps.py +21 -0
  125. pagebuilder/endpoints/api/layout.py +78 -0
  126. pagebuilder/endpoints/api/pages.py +85 -0
  127. pagebuilder/endpoints/api/revisions.py +79 -0
  128. pagebuilder/endpoints/api/uploads.py +92 -0
  129. pagebuilder/endpoints/api/workflow.py +124 -0
  130. pagebuilder/endpoints/seo.py +95 -0
  131. pagebuilder/endpoints/views.py +228 -0
  132. pagebuilder/fields/checkbox-field.tsx +22 -0
  133. pagebuilder/fields/file-picker/file-gallery.tsx +152 -0
  134. pagebuilder/fields/file-picker/image-picker-field.tsx +214 -0
  135. pagebuilder/fields/file-picker/index.ts +6 -0
  136. pagebuilder/fields/file-picker/types.ts +15 -0
  137. pagebuilder/fields/index.ts +9 -0
  138. pagebuilder/fields/media-adapter.ts +47 -0
  139. pagebuilder/hooks/useAutosave.ts +134 -0
  140. pagebuilder/hooks/useEditorForm.ts +193 -0
  141. pagebuilder/hooks/useMediaUploads.ts +96 -0
  142. pagebuilder/hooks/usePageRevisions.ts +59 -0
  143. pagebuilder/hooks/usePageSchedule.ts +72 -0
  144. pagebuilder/hooks/usePageWorkflow.ts +173 -0
  145. pagebuilder/layout_service.py +101 -0
  146. pagebuilder/media_images.py +185 -0
  147. pagebuilder/media_queries.py +92 -0
  148. pagebuilder/media_service.py +222 -0
  149. pagebuilder/models.py +209 -0
  150. pagebuilder/module.py +279 -0
  151. pagebuilder/package.json +18 -0
  152. pagebuilder/pages/LayoutEditor.tsx +183 -0
  153. pagebuilder/pages/MediaLibrary.tsx +249 -0
  154. pagebuilder/pages/PageEditor.tsx +181 -0
  155. pagebuilder/pages/PageList.tsx +129 -0
  156. pagebuilder/pages/PendingReview.tsx +128 -0
  157. pagebuilder/pages/PublicPage.tsx +126 -0
  158. pagebuilder/permissions.py +44 -0
  159. pagebuilder/py.typed +0 -0
  160. pagebuilder/security.py +230 -0
  161. pagebuilder/service/__init__.py +141 -0
  162. pagebuilder/service/_common.py +24 -0
  163. pagebuilder/service/_revisions.py +92 -0
  164. pagebuilder/service/_workflow.py +207 -0
  165. pagebuilder/services.py +19 -0
  166. pagebuilder/settings.py +161 -0
  167. pagebuilder/static/widgets-base.css +57 -0
  168. pagebuilder/translation/apply.ts +54 -0
  169. pagebuilder/translation/extract.ts +141 -0
  170. pagebuilder/translation/index.ts +20 -0
  171. pagebuilder/translation/translatable-fields.ts +65 -0
  172. pagebuilder/translation/translation.test.ts +167 -0
  173. pagebuilder/utils/api.ts +12 -0
  174. pagebuilder/utils/datetime.ts +25 -0
  175. pagebuilder/utils/decorative-image.ts +15 -0
  176. pagebuilder/utils/editorSnapshot.ts +39 -0
  177. pagebuilder/utils/markdown.ts +160 -0
  178. pagebuilder/utils/mediaApi.ts +112 -0
  179. pagebuilder/utils/mediaFormat.ts +19 -0
  180. pagebuilder/utils/pagesApi.ts +111 -0
  181. pagebuilder/utils/parse-list.ts +7 -0
  182. pagebuilder/utils/request.ts +52 -0
  183. pagebuilder/utils/slugify.ts +9 -0
  184. pagebuilder/utils/types.ts +171 -0
  185. pagebuilder/utils/widgetUtils.ts +11 -0
  186. simple_module_pagebuilder-0.0.1.dist-info/METADATA +152 -0
  187. simple_module_pagebuilder-0.0.1.dist-info/RECORD +189 -0
  188. simple_module_pagebuilder-0.0.1.dist-info/WHEEL +4 -0
  189. simple_module_pagebuilder-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,126 @@
1
+ import { Head, usePage } from '@inertiajs/react';
2
+ import { type Data, Render } from '@puckeditor/core';
3
+ import { BrandingHead } from '@simple-module-py/ui/components/BrandingHead';
4
+ import type { SharedProps } from '@simple-module-py/ui/types';
5
+
6
+ import { getLayoutPuckConfig } from '../components/layoutPuckConfig';
7
+ import { migrateContent } from '../components/migrateContent';
8
+ import { getPuckConfig } from '../components/puckConfig';
9
+
10
+ interface Props {
11
+ title: string;
12
+ data: Record<string, unknown>;
13
+ meta_description: string | null;
14
+ og_image: string | null;
15
+ canonical_url?: string | null;
16
+ og_url?: string | null;
17
+ index_in_search?: boolean;
18
+ json_ld?: Record<string, unknown> | null;
19
+ site_name?: string | null;
20
+ twitter_handle?: string | null;
21
+ layout_header?: Record<string, unknown> | null;
22
+ layout_footer?: Record<string, unknown> | null;
23
+ }
24
+
25
+ // Inline JSON-LD as a string requires the document to be safe to embed
26
+ // inside ``<script>...</script>``. ``JSON.stringify`` does not escape
27
+ // the literal sequence ``</`` (or HTML comment openers), so a value like
28
+ // ``"</script><script>alert(1)</script>"`` would break out. The replace
29
+ // runs over keys and string values alike; ``<\/`` parses identically to
30
+ // ``</`` in JSON, so the document round-trips through any consumer.
31
+ function safeJsonLd(doc: Record<string, unknown>): string {
32
+ return JSON.stringify(doc)
33
+ .replace(/<\/(script)/gi, '<\\/$1')
34
+ .replace(/<!--/g, '<\\!--');
35
+ }
36
+
37
+ export default function PublicPage({
38
+ title,
39
+ data,
40
+ meta_description,
41
+ og_image,
42
+ canonical_url,
43
+ og_url,
44
+ index_in_search = true,
45
+ json_ld,
46
+ site_name,
47
+ twitter_handle,
48
+ layout_header,
49
+ layout_footer,
50
+ }: Props) {
51
+ const jsonLdScript = json_ld ? safeJsonLd(json_ld) : null;
52
+ // The design pack is a site-wide branding setting: one site, one look. Its
53
+ // CSS is scoped to a root class, applied here around the *whole* document so
54
+ // the header and footer adopt it too — a class on the page root alone would
55
+ // stop at <main> and leave the chrome reading as a different site.
56
+ //
57
+ // `designPack` is not on the published SharedProps type yet; the cast goes
58
+ // away when the framework release carrying it lands.
59
+ const { branding } = usePage<{ props: SharedProps }>().props as unknown as SharedProps;
60
+ const designPack = (branding as unknown as { designPack?: string | null })?.designPack;
61
+ // Bound once per render because each of the three `migrateContent` calls
62
+ // below needs the same config its `<Render>` gets — migrating against a
63
+ // different palette than the one rendering would resolve zones to slots the
64
+ // renderer doesn't have.
65
+ const pageConfig = getPuckConfig();
66
+ const layoutConfig = getLayoutPuckConfig();
67
+ return (
68
+ <div className={designPack ? `${designPack}-root` : undefined}>
69
+ {/* The admin shell mounts this through AuthenticatedLayout; a public page
70
+ has no layout, so without it the configured brand colour and favicon
71
+ stopped at the sign-in wall. The widgets' `--pb-accent` and their
72
+ solid `bg-primary-800` surfaces read the `--primary` / ramp variables
73
+ it writes, so this is what makes Settings → Branding reach the
74
+ public site. */}
75
+ <BrandingHead />
76
+ <Head title={title}>
77
+ {meta_description && (
78
+ <>
79
+ <meta name="description" content={meta_description} />
80
+ <meta property="og:description" content={meta_description} />
81
+ </>
82
+ )}
83
+ <meta property="og:title" content={title} />
84
+ <meta property="og:type" content="website" />
85
+ {og_url && <meta property="og:url" content={og_url} />}
86
+ {site_name && <meta property="og:site_name" content={site_name} />}
87
+ {og_image && (
88
+ <>
89
+ <meta property="og:image" content={og_image} />
90
+ <meta name="twitter:image" content={og_image} />
91
+ </>
92
+ )}
93
+ <meta name="twitter:card" content={og_image ? 'summary_large_image' : 'summary'} />
94
+ {twitter_handle && <meta name="twitter:site" content={twitter_handle} />}
95
+ {canonical_url && <link rel="canonical" href={canonical_url} />}
96
+ {!index_in_search && <meta name="robots" content="noindex,nofollow" />}
97
+ {jsonLdScript && (
98
+ // Emitting author-composed JSON-LD is the feature. The content is
99
+ // written by permission-holding editors and served under the CSP in
100
+ // PagebuilderSettings.public_csp.
101
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: author-composed JSON-LD is the feature
102
+ <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdScript }} />
103
+ )}
104
+ </Head>
105
+ {layout_header && (
106
+ <header data-testid="site-header">
107
+ <Render
108
+ config={layoutConfig}
109
+ data={migrateContent(layout_header as unknown as Data, layoutConfig)}
110
+ />
111
+ </header>
112
+ )}
113
+ <main>
114
+ <Render config={pageConfig} data={migrateContent(data as unknown as Data, pageConfig)} />
115
+ </main>
116
+ {layout_footer && (
117
+ <footer data-testid="site-footer">
118
+ <Render
119
+ config={layoutConfig}
120
+ data={migrateContent(layout_footer as unknown as Data, layoutConfig)}
121
+ />
122
+ </footer>
123
+ )}
124
+ </div>
125
+ );
126
+ }
@@ -0,0 +1,44 @@
1
+ """Permission constants + suggested role map for the pagebuilder module.
2
+
3
+ The approval workflow carves three permissions out of what was
4
+ previously a single admin surface:
5
+
6
+ * ``pagebuilder.edit`` — draft, save, submit for review, restore.
7
+ * ``pagebuilder.publish`` — publish / unpublish directly.
8
+ * ``pagebuilder.approve`` — approve or reject a submitted page.
9
+
10
+ Enforcement reuses ``simple_module_hosting.permissions.RequiresPermission``
11
+ unchanged — re-exported here so endpoint code only needs one import.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from simple_module_hosting.permissions import RequiresPermission
17
+
18
+ __all__ = [
19
+ "ALL_PERMISSIONS",
20
+ "DEFAULT_ROLE_MAP",
21
+ "PERM_APPROVE",
22
+ "PERM_EDIT",
23
+ "PERM_PUBLISH",
24
+ "ROLE_APPROVER",
25
+ "ROLE_EDITOR",
26
+ "ROLE_PUBLISHER",
27
+ "RequiresPermission",
28
+ ]
29
+
30
+ PERM_EDIT = "pagebuilder.edit"
31
+ PERM_PUBLISH = "pagebuilder.publish"
32
+ PERM_APPROVE = "pagebuilder.approve"
33
+
34
+ ALL_PERMISSIONS: tuple[str, ...] = (PERM_EDIT, PERM_PUBLISH, PERM_APPROVE)
35
+
36
+ ROLE_EDITOR = "pagebuilder_editor"
37
+ ROLE_PUBLISHER = "pagebuilder_publisher"
38
+ ROLE_APPROVER = "pagebuilder_approver"
39
+
40
+ DEFAULT_ROLE_MAP: dict[str, list[str]] = {
41
+ ROLE_EDITOR: [PERM_EDIT],
42
+ ROLE_PUBLISHER: [PERM_EDIT, PERM_PUBLISH],
43
+ ROLE_APPROVER: [PERM_EDIT, PERM_PUBLISH, PERM_APPROVE],
44
+ }
pagebuilder/py.typed ADDED
File without changes
@@ -0,0 +1,230 @@
1
+ """Auth + CSRF dependencies for pagebuilder admin endpoints.
2
+
3
+ The pagebuilder module historically assumed the host gated ``/pagebuilder/*``
4
+ and ``/api/pagebuilder/*`` via session-cookie middleware. A host that
5
+ forgets to mount auth — or one that swaps the users module out — would
6
+ expose page CRUD + media upload to the public internet. These helpers
7
+ move the gate inside the module so it's secure by default.
8
+
9
+ The dependencies are intentionally lazy: importing ``auth.deps`` at
10
+ module top-level would couple the pagebuilder test app to the users
11
+ module's database schema, which we don't want in unit tests.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import secrets
17
+ from typing import Annotated, Any
18
+
19
+ from fastapi import Depends, HTTPException, Request
20
+ from inertia import Inertia
21
+ from simple_module_hosting.inertia_deps import get_inertia
22
+ from starlette.datastructures import MutableHeaders
23
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
24
+
25
+ _CSRF_SESSION_KEY = "pagebuilder_csrf_token"
26
+ _CSRF_HEADER_NAME = "x-csrf-token"
27
+ _UNSAFE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
28
+
29
+
30
+ async def get_current_user_or_401(request: Request) -> Any:
31
+ """Return the authenticated user or raise 401.
32
+
33
+ Reads ``request.state.user`` (set by the host's auth middleware).
34
+ We don't import ``auth.deps.get_current_user`` because it depends on
35
+ the i18n TranslatorDep — the failure mode we care about is "no auth
36
+ middleware mounted at all", which would leave ``request.state.user``
37
+ unset regardless of which dep we use.
38
+ """
39
+ user = getattr(request.state, "user", None)
40
+ if user is None:
41
+ raise HTTPException(status_code=401, detail="Not authenticated")
42
+ return user
43
+
44
+
45
+ def _get_or_create_csrf_token(request: Request) -> str | None:
46
+ """Generate (and persist) the per-session CSRF token.
47
+
48
+ Returns ``None`` when the request has no session — happens in the
49
+ bare-FastAPI test app that doesn't mount SessionMiddleware. Callers
50
+ can treat ``None`` as "skip CSRF enforcement" since without a session
51
+ we have no anchor to bind the token to anyway.
52
+ """
53
+ session = request.scope.get("session")
54
+ if session is None:
55
+ return None
56
+ token = session.get(_CSRF_SESSION_KEY)
57
+ if not token:
58
+ token = secrets.token_urlsafe(32)
59
+ session[_CSRF_SESSION_KEY] = token
60
+ return token
61
+
62
+
63
+ def get_csrf_token(request: Request) -> str:
64
+ """Public dep that returns the current CSRF token, generating if needed.
65
+
66
+ Returns an empty string when no session is mounted so view code can
67
+ pass it straight to Inertia props without a None-check.
68
+ """
69
+ return _get_or_create_csrf_token(request) or ""
70
+
71
+
72
+ async def verify_csrf(request: Request) -> None:
73
+ """Enforce ``X-CSRF-Token`` header on every mutating admin request.
74
+
75
+ Skipped on safe methods (GET / HEAD / OPTIONS) and when no session is
76
+ mounted (test harnesses without SessionMiddleware). The header value
77
+ is compared in constant time against the per-session token.
78
+ """
79
+ if request.method not in _UNSAFE_METHODS:
80
+ return
81
+ session = request.scope.get("session")
82
+ if session is None:
83
+ return
84
+ expected = session.get(_CSRF_SESSION_KEY)
85
+ received = request.headers.get(_CSRF_HEADER_NAME)
86
+ if not expected or not received or not secrets.compare_digest(
87
+ str(expected), str(received)
88
+ ):
89
+ raise HTTPException(status_code=403, detail="Invalid or missing CSRF token")
90
+
91
+
92
+ async def share_csrf_to_inertia(
93
+ request: Request,
94
+ inertia: Annotated[Inertia, Depends(get_inertia)],
95
+ ) -> None:
96
+ """Surface the CSRF token to the React client via shared Inertia props.
97
+
98
+ The companion :class:`CsrfCookieMiddleware` writes the same value to
99
+ a non-``HttpOnly`` cookie so the JS layer can read it back for
100
+ sub-resources that don't go through Inertia page props (notably
101
+ multipart media uploads, which can't easily include the token via
102
+ JSON props).
103
+
104
+ We share via Inertia props rather than a route-level
105
+ ``response.set_cookie`` because FastAPI doesn't merge a dependency's
106
+ cookie writes into a ``Response`` returned by the handler — and the
107
+ Inertia view handlers all return ``InertiaResponse`` directly.
108
+ """
109
+ settings = _get_settings(request)
110
+ if not settings.csrf_protect:
111
+ return
112
+ token = _get_or_create_csrf_token(request)
113
+ if token is None:
114
+ return
115
+ inertia.share(csrf={"token": token, "header": "X-CSRF-Token"})
116
+
117
+
118
+ class CsrfCookieMiddleware:
119
+ """Mirror the session's CSRF token to a JS-readable cookie.
120
+
121
+ Lives in a middleware (not the share dep) because FastAPI doesn't
122
+ merge a dep's cookie writes into a ``Response`` returned by the
123
+ handler — Inertia views always return one, so the dep route is a
124
+ dead end for cookie setting.
125
+
126
+ Two short-circuits keep this off the hot path: requests outside the
127
+ admin prefixes (public viewer, static assets) skip the response
128
+ wrapper entirely, and the wrapper itself emits no ``Set-Cookie`` when
129
+ the request already arrived with a cookie matching the session
130
+ token.
131
+ """
132
+
133
+ def __init__(
134
+ self,
135
+ app: ASGIApp,
136
+ cookie_name: str,
137
+ admin_prefixes: tuple[str, ...] = (),
138
+ ) -> None:
139
+ self.app = app
140
+ self.cookie_name = cookie_name
141
+ self.admin_prefixes = admin_prefixes
142
+ self._cookie_attr = f"{cookie_name}="
143
+
144
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
145
+ if scope["type"] != "http":
146
+ await self.app(scope, receive, send)
147
+ return
148
+ if self.admin_prefixes and not any(
149
+ scope["path"].startswith(p) for p in self.admin_prefixes
150
+ ):
151
+ await self.app(scope, receive, send)
152
+ return
153
+
154
+ async def send_wrapper(message: Message) -> None:
155
+ if message["type"] == "http.response.start":
156
+ session = scope.get("session")
157
+ token = (
158
+ session.get(_CSRF_SESSION_KEY) if isinstance(session, dict) else None
159
+ )
160
+ if token and _request_cookie_value(scope, self._cookie_attr) != token:
161
+ headers = MutableHeaders(scope=message)
162
+ headers.append(
163
+ "set-cookie",
164
+ f"{self.cookie_name}={token}; Path=/; SameSite=Strict",
165
+ )
166
+ await send(message)
167
+
168
+ await self.app(scope, receive, send_wrapper)
169
+
170
+
171
+ def _request_cookie_value(scope: Scope, attr: str) -> str | None:
172
+ """Read the cookie value matching ``attr`` (e.g. ``"pagebuilder_csrf="``).
173
+
174
+ Hand-rolled instead of using ``starlette.requests.cookies`` to avoid
175
+ re-parsing the whole Cookie header for one attribute on every admin
176
+ request.
177
+ """
178
+ for name, value in scope.get("headers", ()):
179
+ if name == b"cookie":
180
+ for piece in value.decode("latin-1").split(";"):
181
+ trimmed = piece.lstrip()
182
+ if trimmed.startswith(attr):
183
+ return trimmed[len(attr):]
184
+ return None
185
+
186
+
187
+ def _get_settings(request: Request):
188
+ """Resolve the pagebuilder settings from app state.
189
+
190
+ Mirrors :func:`pagebuilder.deps.get_settings` but inlined to avoid an
191
+ import cycle: ``deps`` imports from settings, settings is fine, but
192
+ security depending on deps would force security to import the
193
+ MediaService transitively.
194
+ """
195
+ return request.app.state.pagebuilder.settings
196
+
197
+
198
+ def build_admin_dependencies(
199
+ *, requires_auth: bool, csrf_protect: bool
200
+ ) -> list[Any]:
201
+ """Mutating-side dependencies applied to the API router.
202
+
203
+ Returned items are ``Depends(...)`` wrappers ready to be passed to
204
+ ``APIRouter(dependencies=...)``. The dependency order matters: auth
205
+ runs first so an unauthenticated request gets 401 (the more useful
206
+ error) before CSRF fails it with 403.
207
+ """
208
+ deps: list[Any] = []
209
+ if requires_auth:
210
+ deps.append(Depends(get_current_user_or_401))
211
+ if csrf_protect:
212
+ deps.append(Depends(verify_csrf))
213
+ return deps
214
+
215
+
216
+ def build_view_dependencies(
217
+ *, requires_auth: bool, csrf_protect: bool
218
+ ) -> list[Any]:
219
+ """View-side dependencies applied to the Inertia view router.
220
+
221
+ Mirrors :func:`build_admin_dependencies` but emits the CSRF token
222
+ via Inertia shared-props + cookie instead of enforcing it (Inertia
223
+ GETs are safe methods, so verification is a no-op anyway).
224
+ """
225
+ deps: list[Any] = []
226
+ if requires_auth:
227
+ deps.append(Depends(get_current_user_or_401))
228
+ if csrf_protect:
229
+ deps.append(Depends(share_csrf_to_inertia))
230
+ return deps
@@ -0,0 +1,141 @@
1
+ """Business logic for pagebuilder pages.
2
+
3
+ Service is constructed per-request from the injected ``AsyncSession``;
4
+ the DB session's commit-on-write behavior is provided by the framework's
5
+ ``get_db`` dependency.
6
+
7
+ ``PagesService`` is composed from two mixins so no single file carries the
8
+ whole surface: :mod:`._workflow` holds the status transitions and
9
+ :mod:`._revisions` the revision history. Page CRUD and queries stay here.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from fastapi import HTTPException
15
+ from simple_module_core.events import EventBus
16
+ from sqlalchemy import delete as sa_delete
17
+ from sqlalchemy.exc import IntegrityError
18
+ from sqlalchemy.ext.asyncio import AsyncSession
19
+ from sqlmodel import select
20
+
21
+ from pagebuilder.contracts.events import PageDeleted
22
+ from pagebuilder.contracts.schemas import PageCreate, PageUpdate
23
+ from pagebuilder.models import Page, PageRevision, PageStatus
24
+ from pagebuilder.service._common import _UNSET, _normalize_to_utc
25
+ from pagebuilder.service._revisions import RevisionsMixin
26
+ from pagebuilder.service._workflow import WorkflowMixin
27
+
28
+ # _UNSET is re-exported: endpoints import it to express "field absent".
29
+ __all__ = ["_UNSET", "PagesService"]
30
+
31
+
32
+ class PagesService(WorkflowMixin, RevisionsMixin):
33
+ def __init__(self, db: AsyncSession, event_bus: EventBus | None = None) -> None:
34
+ self.db = db
35
+ # Optional so every existing caller — and every test — keeps working;
36
+ # only the delete path uses it.
37
+ self.event_bus = event_bus
38
+
39
+ async def list_pages(self) -> list[Page]:
40
+ result = await self.db.execute(select(Page).order_by(Page.id.desc()))
41
+ return list(result.scalars().all())
42
+
43
+ async def list_pending(self) -> list[Page]:
44
+ """Pages in ``submitted_for_review`` — the approver queue."""
45
+ result = await self.db.execute(
46
+ select(Page)
47
+ .where(Page.status == PageStatus.SUBMITTED_FOR_REVIEW)
48
+ .order_by(Page.id.desc())
49
+ )
50
+ return list(result.scalars().all())
51
+
52
+ async def get_page(self, page_id: int) -> Page:
53
+ page = await self.db.get(Page, page_id)
54
+ if page is None:
55
+ raise HTTPException(status_code=404, detail="Page not found")
56
+ return page
57
+
58
+ async def get_by_slug_published(self, slug: str) -> Page | None:
59
+ result = await self.db.execute(
60
+ select(Page).where(
61
+ Page.slug == slug,
62
+ Page.status == PageStatus.PUBLISHED,
63
+ )
64
+ )
65
+ return result.scalars().first()
66
+
67
+ async def list_indexable_published(self) -> list[Page]:
68
+ """Published pages eligible for sitemap inclusion.
69
+
70
+ Excludes ``index_in_search=False`` (used both by the sitemap
71
+ generator and any external indexing job). Ordered by ``updated_at``
72
+ descending so the freshest content surfaces first when a consumer
73
+ only reads the head of the list.
74
+ """
75
+ result = await self.db.execute(
76
+ select(Page)
77
+ .where(
78
+ Page.status == PageStatus.PUBLISHED,
79
+ Page.index_in_search.is_(True), # type: ignore[union-attr]
80
+ )
81
+ .order_by(Page.updated_at.desc())
82
+ )
83
+ return list(result.scalars().all())
84
+
85
+ async def create(self, data: PageCreate) -> Page:
86
+ page = Page(
87
+ title=data.title,
88
+ slug=data.slug,
89
+ meta_description=data.meta_description,
90
+ og_image=data.og_image,
91
+ canonical_url=data.canonical_url,
92
+ index_in_search=data.index_in_search,
93
+ json_ld=data.json_ld,
94
+ draft_data=data.draft_data,
95
+ publish_at=_normalize_to_utc(data.publish_at),
96
+ unpublish_at=_normalize_to_utc(data.unpublish_at),
97
+ status=PageStatus.DRAFT,
98
+ )
99
+ self.db.add(page)
100
+ try:
101
+ await self.db.flush()
102
+ except IntegrityError as exc:
103
+ await self.db.rollback()
104
+ raise HTTPException(status_code=409, detail="Slug already in use") from exc
105
+ await self.db.refresh(page)
106
+ return page
107
+
108
+ async def update(self, page_id: int, data: PageUpdate) -> Page:
109
+ page = await self.get_page(page_id)
110
+ update = data.model_dump(exclude_unset=True)
111
+ for field, value in update.items():
112
+ setattr(page, field, value)
113
+ self.db.add(page)
114
+ try:
115
+ await self.db.flush()
116
+ except IntegrityError as exc:
117
+ await self.db.rollback()
118
+ raise HTTPException(status_code=409, detail="Slug already in use") from exc
119
+ await self.db.refresh(page)
120
+ return page
121
+
122
+ async def delete(self, page_id: int) -> None:
123
+ page = await self.get_page(page_id)
124
+ slug = page.slug
125
+ await self.db.execute(
126
+ sa_delete(PageRevision).where(PageRevision.page_id == page_id)
127
+ )
128
+ await self.db.delete(page)
129
+ await self.db.flush()
130
+ if self.event_bus is None:
131
+ return
132
+ # Commit *before* publishing. A subscriber runs on its own session, so
133
+ # on SQLite it would hit "database is locked" against this request's
134
+ # still-open write transaction — and the bus swallows a handler error
135
+ # into a log line, so the row would quietly survive.
136
+ await self.db.commit()
137
+ # Announce it so modules keying their own rows to this page can drop
138
+ # them. Without this a stale row does not merely dangle — SQLite reuses
139
+ # the id, so it re-attaches to the next page created.
140
+ await self.event_bus.publish(PageDeleted(page_id=page_id, slug=slug))
141
+
@@ -0,0 +1,24 @@
1
+ """Sentinels and helpers shared by the service mixins.
2
+
3
+ Lives in its own module so ``_workflow`` can import it without reaching back
4
+ into ``pagebuilder.service``, which imports ``_workflow`` in turn.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import UTC, datetime
10
+
11
+
12
+ class _Unset:
13
+ """Sentinel separating ``field=None`` (clear) from "field not provided"."""
14
+
15
+
16
+ _UNSET = _Unset()
17
+
18
+
19
+ def _normalize_to_utc(value: datetime | None) -> datetime | None:
20
+ if value is None:
21
+ return None
22
+ if value.tzinfo is None:
23
+ return value.replace(tzinfo=UTC)
24
+ return value.astimezone(UTC)
@@ -0,0 +1,92 @@
1
+ """Revision recording, history, diffing, and restore.
2
+
3
+ Mixed into :class:`~pagebuilder.service.PagesService`. Split out to keep each
4
+ file focused; the methods are unchanged and still rely on ``self.db`` and
5
+ ``self.get_page`` from the composing class.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from fastapi import HTTPException
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+ from sqlmodel import select
13
+
14
+ from pagebuilder.diff import revision_diff
15
+ from pagebuilder.models import Page, PageRevision, RevisionEvent
16
+
17
+
18
+ class RevisionsMixin:
19
+ """Revision behaviour for :class:`PagesService`."""
20
+
21
+ db: AsyncSession
22
+
23
+ async def get_page(self, page_id: int) -> Page: # pragma: no cover - provided by PagesService
24
+ raise NotImplementedError
25
+
26
+ def _record_revision(
27
+ self,
28
+ page: Page,
29
+ *,
30
+ event: RevisionEvent,
31
+ note: str | None = None,
32
+ ) -> PageRevision:
33
+ """Append an audit row mirroring *page*'s current draft state.
34
+
35
+ Publish / approve callers overwrite ``published_data`` with
36
+ ``draft_data`` before recording, so ``draft_data`` is the single
37
+ snapshot source every event needs. Caller owns the flush so the
38
+ page row + revision land in one transaction.
39
+ """
40
+ revision = PageRevision(
41
+ page_id=page.id or 0,
42
+ title=page.title,
43
+ meta_description=page.meta_description,
44
+ og_image=page.og_image,
45
+ data=page.draft_data,
46
+ event=event,
47
+ note=note,
48
+ )
49
+ self.db.add(revision)
50
+ return revision
51
+
52
+
53
+ async def list_revisions(self, page_id: int) -> list[PageRevision]:
54
+ result = await self.db.execute(
55
+ select(PageRevision)
56
+ .where(PageRevision.page_id == page_id)
57
+ .order_by(PageRevision.id.desc())
58
+ )
59
+ return list(result.scalars().all())
60
+
61
+ async def get_revision(self, page_id: int, revision_id: int) -> PageRevision:
62
+ revision = await self.db.get(PageRevision, revision_id)
63
+ if revision is None or revision.page_id != page_id:
64
+ raise HTTPException(status_code=404, detail="Revision not found")
65
+ return revision
66
+
67
+ async def diff_revisions(self, page_id: int, before_id: int, after_id: int) -> dict:
68
+ """Block-level diff between two revisions of the same page.
69
+
70
+ Both revisions are validated to belong to ``page_id`` via
71
+ :meth:`get_revision`, so a cross-page id pair surfaces as a 404
72
+ rather than silently diffing across pages.
73
+ """
74
+ before = await self.get_revision(page_id, before_id)
75
+ after = await self.get_revision(page_id, after_id)
76
+ result = revision_diff(before, after)
77
+ result["before_id"] = before.id
78
+ result["after_id"] = after.id
79
+ return result
80
+
81
+ async def restore_revision(self, page_id: int, revision_id: int) -> Page:
82
+ """Copy a revision's payload into the page's draft (does not publish)."""
83
+ page = await self.get_page(page_id)
84
+ revision = await self.get_revision(page_id, revision_id)
85
+ page.title = revision.title
86
+ page.meta_description = revision.meta_description
87
+ page.og_image = revision.og_image
88
+ page.draft_data = revision.data
89
+ self.db.add(page)
90
+ await self.db.flush()
91
+ await self.db.refresh(page)
92
+ return page