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.
- pagebuilder/__init__.py +0 -0
- pagebuilder/components/DiffSummary.tsx +107 -0
- pagebuilder/components/ScheduledBadge.tsx +34 -0
- pagebuilder/components/StatusBadge.tsx +21 -0
- pagebuilder/components/blockRegistry.test.ts +56 -0
- pagebuilder/components/blockRegistry.ts +80 -0
- pagebuilder/components/blocks/Button.tsx +142 -0
- pagebuilder/components/blocks/Columns.tsx +64 -0
- pagebuilder/components/blocks/Heading.tsx +92 -0
- pagebuilder/components/blocks/Image.tsx +125 -0
- pagebuilder/components/blocks/MediaPicker.tsx +178 -0
- pagebuilder/components/blocks/Spacer.tsx +31 -0
- pagebuilder/components/blocks/Text.tsx +80 -0
- pagebuilder/components/editor/PageEditorToolbar.tsx +169 -0
- pagebuilder/components/editor/RevisionHistoryPanel.tsx +95 -0
- pagebuilder/components/editor/SchedulePanel.tsx +85 -0
- pagebuilder/components/editor/SeoSettingsPanel.tsx +119 -0
- pagebuilder/components/layoutPuckConfig.tsx +109 -0
- pagebuilder/components/media/MediaFilters.tsx +83 -0
- pagebuilder/components/media/MediaFolderSidebar.tsx +97 -0
- pagebuilder/components/media/MediaGrid.tsx +76 -0
- pagebuilder/components/media/MediaHeader.tsx +37 -0
- pagebuilder/components/media/MediaUploadQueue.tsx +103 -0
- pagebuilder/components/media/types.ts +20 -0
- pagebuilder/components/migrateContent.test.ts +146 -0
- pagebuilder/components/migrateContent.ts +135 -0
- pagebuilder/components/puckConfig.test.tsx +118 -0
- pagebuilder/components/puckConfig.tsx +121 -0
- pagebuilder/components/widgetCatalog.test.tsx +123 -0
- pagebuilder/components/widgetCatalog.ts +208 -0
- pagebuilder/components/widgets/_internal/accent-text.tsx +60 -0
- pagebuilder/components/widgets/_internal/disclosure-chevron.tsx +14 -0
- pagebuilder/components/widgets/_internal/eyebrow-split-section.tsx +71 -0
- pagebuilder/components/widgets/_internal/link-arrow.tsx +23 -0
- pagebuilder/components/widgets/_internal/rich-text.tsx +243 -0
- pagebuilder/components/widgets/_internal/solid-icons.tsx +73 -0
- pagebuilder/components/widgets/_shared/background-media.tsx +74 -0
- pagebuilder/components/widgets/_shared/content-media.tsx +43 -0
- pagebuilder/components/widgets/_shared/cta-button.tsx +69 -0
- pagebuilder/components/widgets/_shared/empty-placeholder.tsx +15 -0
- pagebuilder/components/widgets/_shared/eyebrow-text.tsx +27 -0
- pagebuilder/components/widgets/_shared/heading.tsx +88 -0
- pagebuilder/components/widgets/_shared/index.ts +39 -0
- pagebuilder/components/widgets/_shared/mask.ts +54 -0
- pagebuilder/components/widgets/_shared/section.tsx +60 -0
- pagebuilder/components/widgets/_shared/social-icon.tsx +71 -0
- pagebuilder/components/widgets/_shared/text-link.tsx +47 -0
- pagebuilder/components/widgets/accordion-widget.tsx +64 -0
- pagebuilder/components/widgets/alert-widget.tsx +47 -0
- pagebuilder/components/widgets/app-store-badges-widget.tsx +91 -0
- pagebuilder/components/widgets/article-cards-render.tsx +132 -0
- pagebuilder/components/widgets/article-cards-widget.tsx +72 -0
- pagebuilder/components/widgets/call-to-action-render.tsx +285 -0
- pagebuilder/components/widgets/call-to-action-styles.ts +28 -0
- pagebuilder/components/widgets/call-to-action-widget.tsx +83 -0
- pagebuilder/components/widgets/carousel-widget.tsx +97 -0
- pagebuilder/components/widgets/centered-hero-widget.tsx +54 -0
- pagebuilder/components/widgets/colored-action-list-widget.tsx +117 -0
- pagebuilder/components/widgets/column-widget.tsx +63 -0
- pagebuilder/components/widgets/contact-cards-widget.tsx +172 -0
- pagebuilder/components/widgets/contact-form-render.tsx +293 -0
- pagebuilder/components/widgets/contact-form-widget.tsx +69 -0
- pagebuilder/components/widgets/container-widget.tsx +93 -0
- pagebuilder/components/widgets/definition-list-widget.tsx +144 -0
- pagebuilder/components/widgets/divider-widget.tsx +56 -0
- pagebuilder/components/widgets/eyebrow-section-widget.tsx +56 -0
- pagebuilder/components/widgets/faq-render.tsx +268 -0
- pagebuilder/components/widgets/faq-widget.tsx +64 -0
- pagebuilder/components/widgets/feature-card-widget.tsx +98 -0
- pagebuilder/components/widgets/feature-cards-render.tsx +258 -0
- pagebuilder/components/widgets/feature-cards-styles.ts +107 -0
- pagebuilder/components/widgets/feature-cards-widget.tsx +133 -0
- pagebuilder/components/widgets/feature-grid-widget.tsx +118 -0
- pagebuilder/components/widgets/feature-showcase-widget.tsx +118 -0
- pagebuilder/components/widgets/gallery-widget.tsx +139 -0
- pagebuilder/components/widgets/grid-widget.tsx +59 -0
- pagebuilder/components/widgets/hero-widget.tsx +292 -0
- pagebuilder/components/widgets/html-widget.tsx +42 -0
- pagebuilder/components/widgets/iframe-widget.tsx +44 -0
- pagebuilder/components/widgets/leaderboard-widget.tsx +203 -0
- pagebuilder/components/widgets/list-widget.tsx +56 -0
- pagebuilder/components/widgets/logo-cloud-widget.tsx +127 -0
- pagebuilder/components/widgets/markdown-widget.tsx +148 -0
- pagebuilder/components/widgets/media-object-layout.ts +156 -0
- pagebuilder/components/widgets/media-object-render.tsx +284 -0
- pagebuilder/components/widgets/media-object-text.tsx +202 -0
- pagebuilder/components/widgets/media-object-widget.tsx +176 -0
- pagebuilder/components/widgets/newsletter-widget.tsx +217 -0
- pagebuilder/components/widgets/page-header-widget.tsx +118 -0
- pagebuilder/components/widgets/prose-section-widget.tsx +99 -0
- pagebuilder/components/widgets/quote-widget.tsx +51 -0
- pagebuilder/components/widgets/row-widget.tsx +74 -0
- pagebuilder/components/widgets/section-intro-widget.tsx +143 -0
- pagebuilder/components/widgets/signup-banner-widget.tsx +50 -0
- pagebuilder/components/widgets/simple-action-list-widget.tsx +91 -0
- pagebuilder/components/widgets/site-footer-widget.tsx +97 -0
- pagebuilder/components/widgets/site-header-nav.tsx +153 -0
- pagebuilder/components/widgets/site-header-widget.tsx +182 -0
- pagebuilder/components/widgets/social-banner-widget.tsx +83 -0
- pagebuilder/components/widgets/stats-widget.tsx +200 -0
- pagebuilder/components/widgets/steps-widget.tsx +211 -0
- pagebuilder/components/widgets/story-cards-widget.tsx +182 -0
- pagebuilder/components/widgets/table-widget.tsx +86 -0
- pagebuilder/components/widgets/tabs-widget.tsx +95 -0
- pagebuilder/components/widgets/tags-widget.tsx +85 -0
- pagebuilder/components/widgets/testimonial-widget.tsx +102 -0
- pagebuilder/components/widgets/timeline-layout.ts +73 -0
- pagebuilder/components/widgets/timeline-render.tsx +294 -0
- pagebuilder/components/widgets/timeline-widget.tsx +106 -0
- pagebuilder/components/widgets/under-construction-widget.tsx +40 -0
- pagebuilder/components/widgets/video-widget.tsx +103 -0
- pagebuilder/components/widgets/welcome-widget.tsx +38 -0
- pagebuilder/contracts/__init__.py +57 -0
- pagebuilder/contracts/events.py +25 -0
- pagebuilder/contracts/schemas.py +239 -0
- pagebuilder/conversion/blocks-to-html.test.ts +105 -0
- pagebuilder/conversion/blocks-to-html.ts +102 -0
- pagebuilder/conversion/index.ts +2 -0
- pagebuilder/conversion/types.ts +5 -0
- pagebuilder/deps.py +26 -0
- pagebuilder/diff.py +129 -0
- pagebuilder/endpoints/__init__.py +0 -0
- pagebuilder/endpoints/api/__init__.py +21 -0
- pagebuilder/endpoints/api/_deps.py +21 -0
- pagebuilder/endpoints/api/layout.py +78 -0
- pagebuilder/endpoints/api/pages.py +85 -0
- pagebuilder/endpoints/api/revisions.py +79 -0
- pagebuilder/endpoints/api/uploads.py +92 -0
- pagebuilder/endpoints/api/workflow.py +124 -0
- pagebuilder/endpoints/seo.py +95 -0
- pagebuilder/endpoints/views.py +228 -0
- pagebuilder/fields/checkbox-field.tsx +22 -0
- pagebuilder/fields/file-picker/file-gallery.tsx +152 -0
- pagebuilder/fields/file-picker/image-picker-field.tsx +214 -0
- pagebuilder/fields/file-picker/index.ts +6 -0
- pagebuilder/fields/file-picker/types.ts +15 -0
- pagebuilder/fields/index.ts +9 -0
- pagebuilder/fields/media-adapter.ts +47 -0
- pagebuilder/hooks/useAutosave.ts +134 -0
- pagebuilder/hooks/useEditorForm.ts +193 -0
- pagebuilder/hooks/useMediaUploads.ts +96 -0
- pagebuilder/hooks/usePageRevisions.ts +59 -0
- pagebuilder/hooks/usePageSchedule.ts +72 -0
- pagebuilder/hooks/usePageWorkflow.ts +173 -0
- pagebuilder/layout_service.py +101 -0
- pagebuilder/media_images.py +185 -0
- pagebuilder/media_queries.py +92 -0
- pagebuilder/media_service.py +222 -0
- pagebuilder/models.py +209 -0
- pagebuilder/module.py +279 -0
- pagebuilder/package.json +18 -0
- pagebuilder/pages/LayoutEditor.tsx +183 -0
- pagebuilder/pages/MediaLibrary.tsx +249 -0
- pagebuilder/pages/PageEditor.tsx +181 -0
- pagebuilder/pages/PageList.tsx +129 -0
- pagebuilder/pages/PendingReview.tsx +128 -0
- pagebuilder/pages/PublicPage.tsx +126 -0
- pagebuilder/permissions.py +44 -0
- pagebuilder/py.typed +0 -0
- pagebuilder/security.py +230 -0
- pagebuilder/service/__init__.py +141 -0
- pagebuilder/service/_common.py +24 -0
- pagebuilder/service/_revisions.py +92 -0
- pagebuilder/service/_workflow.py +207 -0
- pagebuilder/services.py +19 -0
- pagebuilder/settings.py +161 -0
- pagebuilder/static/widgets-base.css +57 -0
- pagebuilder/translation/apply.ts +54 -0
- pagebuilder/translation/extract.ts +141 -0
- pagebuilder/translation/index.ts +20 -0
- pagebuilder/translation/translatable-fields.ts +65 -0
- pagebuilder/translation/translation.test.ts +167 -0
- pagebuilder/utils/api.ts +12 -0
- pagebuilder/utils/datetime.ts +25 -0
- pagebuilder/utils/decorative-image.ts +15 -0
- pagebuilder/utils/editorSnapshot.ts +39 -0
- pagebuilder/utils/markdown.ts +160 -0
- pagebuilder/utils/mediaApi.ts +112 -0
- pagebuilder/utils/mediaFormat.ts +19 -0
- pagebuilder/utils/pagesApi.ts +111 -0
- pagebuilder/utils/parse-list.ts +7 -0
- pagebuilder/utils/request.ts +52 -0
- pagebuilder/utils/slugify.ts +9 -0
- pagebuilder/utils/types.ts +171 -0
- pagebuilder/utils/widgetUtils.ts +11 -0
- simple_module_pagebuilder-0.0.1.dist-info/METADATA +152 -0
- simple_module_pagebuilder-0.0.1.dist-info/RECORD +189 -0
- simple_module_pagebuilder-0.0.1.dist-info/WHEEL +4 -0
- 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
|
pagebuilder/security.py
ADDED
|
@@ -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
|