skrift 0.1.0a1__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.
- skrift/__init__.py +1 -0
- skrift/__main__.py +17 -0
- skrift/admin/__init__.py +11 -0
- skrift/admin/controller.py +452 -0
- skrift/admin/navigation.py +105 -0
- skrift/alembic/env.py +91 -0
- skrift/alembic/script.py.mako +26 -0
- skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py +70 -0
- skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py +57 -0
- skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py +31 -0
- skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py +43 -0
- skrift/alembic/versions/20260122_200000_add_settings_table.py +38 -0
- skrift/alembic.ini +77 -0
- skrift/asgi.py +545 -0
- skrift/auth/__init__.py +58 -0
- skrift/auth/guards.py +130 -0
- skrift/auth/roles.py +94 -0
- skrift/auth/services.py +184 -0
- skrift/cli.py +45 -0
- skrift/config.py +192 -0
- skrift/controllers/__init__.py +4 -0
- skrift/controllers/auth.py +371 -0
- skrift/controllers/web.py +67 -0
- skrift/db/__init__.py +3 -0
- skrift/db/base.py +7 -0
- skrift/db/models/__init__.py +6 -0
- skrift/db/models/page.py +26 -0
- skrift/db/models/role.py +56 -0
- skrift/db/models/setting.py +13 -0
- skrift/db/models/user.py +36 -0
- skrift/db/services/__init__.py +1 -0
- skrift/db/services/page_service.py +217 -0
- skrift/db/services/setting_service.py +206 -0
- skrift/lib/__init__.py +3 -0
- skrift/lib/exceptions.py +168 -0
- skrift/lib/template.py +108 -0
- skrift/setup/__init__.py +14 -0
- skrift/setup/config_writer.py +211 -0
- skrift/setup/controller.py +751 -0
- skrift/setup/middleware.py +89 -0
- skrift/setup/providers.py +163 -0
- skrift/setup/state.py +134 -0
- skrift/static/css/style.css +998 -0
- skrift/templates/admin/admin.html +19 -0
- skrift/templates/admin/base.html +24 -0
- skrift/templates/admin/pages/edit.html +32 -0
- skrift/templates/admin/pages/list.html +62 -0
- skrift/templates/admin/settings/site.html +32 -0
- skrift/templates/admin/users/list.html +58 -0
- skrift/templates/admin/users/roles.html +42 -0
- skrift/templates/auth/login.html +125 -0
- skrift/templates/base.html +52 -0
- skrift/templates/error-404.html +19 -0
- skrift/templates/error-500.html +19 -0
- skrift/templates/error.html +19 -0
- skrift/templates/index.html +9 -0
- skrift/templates/page.html +26 -0
- skrift/templates/setup/admin.html +24 -0
- skrift/templates/setup/auth.html +110 -0
- skrift/templates/setup/base.html +407 -0
- skrift/templates/setup/complete.html +17 -0
- skrift/templates/setup/database.html +125 -0
- skrift/templates/setup/restart.html +28 -0
- skrift/templates/setup/site.html +39 -0
- skrift-0.1.0a1.dist-info/METADATA +233 -0
- skrift-0.1.0a1.dist-info/RECORD +68 -0
- skrift-0.1.0a1.dist-info/WHEEL +4 -0
- skrift-0.1.0a1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Page service for CRUD operations on pages."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import select, and_
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
|
+
|
|
9
|
+
from skrift.db.models import Page
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def list_pages(
|
|
13
|
+
db_session: AsyncSession,
|
|
14
|
+
published_only: bool = False,
|
|
15
|
+
user_id: UUID | None = None,
|
|
16
|
+
limit: int | None = None,
|
|
17
|
+
offset: int = 0,
|
|
18
|
+
) -> list[Page]:
|
|
19
|
+
"""List pages with optional filtering.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
db_session: Database session
|
|
23
|
+
published_only: Only return published pages
|
|
24
|
+
user_id: Filter by user ID (author)
|
|
25
|
+
limit: Maximum number of results
|
|
26
|
+
offset: Number of results to skip
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
List of Page objects
|
|
30
|
+
"""
|
|
31
|
+
query = select(Page)
|
|
32
|
+
|
|
33
|
+
# Build filters
|
|
34
|
+
filters = []
|
|
35
|
+
if published_only:
|
|
36
|
+
filters.append(Page.is_published == True)
|
|
37
|
+
if user_id:
|
|
38
|
+
filters.append(Page.user_id == user_id)
|
|
39
|
+
|
|
40
|
+
if filters:
|
|
41
|
+
query = query.where(and_(*filters))
|
|
42
|
+
|
|
43
|
+
# Order by published date (newest first), then created date
|
|
44
|
+
query = query.order_by(Page.published_at.desc().nullslast(), Page.created_at.desc())
|
|
45
|
+
|
|
46
|
+
# Apply pagination
|
|
47
|
+
if offset:
|
|
48
|
+
query = query.offset(offset)
|
|
49
|
+
if limit:
|
|
50
|
+
query = query.limit(limit)
|
|
51
|
+
|
|
52
|
+
result = await db_session.execute(query)
|
|
53
|
+
return list(result.scalars().all())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def get_page_by_slug(
|
|
57
|
+
db_session: AsyncSession,
|
|
58
|
+
slug: str,
|
|
59
|
+
published_only: bool = False,
|
|
60
|
+
) -> Page | None:
|
|
61
|
+
"""Get a single page by slug.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
db_session: Database session
|
|
65
|
+
slug: Page slug
|
|
66
|
+
published_only: Only return if published
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Page object or None if not found
|
|
70
|
+
"""
|
|
71
|
+
query = select(Page).where(Page.slug == slug)
|
|
72
|
+
|
|
73
|
+
if published_only:
|
|
74
|
+
query = query.where(Page.is_published == True)
|
|
75
|
+
|
|
76
|
+
result = await db_session.execute(query)
|
|
77
|
+
return result.scalar_one_or_none()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def get_page_by_id(
|
|
81
|
+
db_session: AsyncSession,
|
|
82
|
+
page_id: UUID,
|
|
83
|
+
) -> Page | None:
|
|
84
|
+
"""Get a single page by ID.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
db_session: Database session
|
|
88
|
+
page_id: Page UUID
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Page object or None if not found
|
|
92
|
+
"""
|
|
93
|
+
result = await db_session.execute(select(Page).where(Page.id == page_id))
|
|
94
|
+
return result.scalar_one_or_none()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def create_page(
|
|
98
|
+
db_session: AsyncSession,
|
|
99
|
+
slug: str,
|
|
100
|
+
title: str,
|
|
101
|
+
content: str = "",
|
|
102
|
+
is_published: bool = False,
|
|
103
|
+
published_at: datetime | None = None,
|
|
104
|
+
user_id: UUID | None = None,
|
|
105
|
+
) -> Page:
|
|
106
|
+
"""Create a new page.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
db_session: Database session
|
|
110
|
+
slug: Unique page slug
|
|
111
|
+
title: Page title
|
|
112
|
+
content: Page content (HTML or markdown)
|
|
113
|
+
is_published: Whether page is published
|
|
114
|
+
published_at: Publication timestamp
|
|
115
|
+
user_id: Author user ID (optional)
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Created Page object
|
|
119
|
+
"""
|
|
120
|
+
page = Page(
|
|
121
|
+
slug=slug,
|
|
122
|
+
title=title,
|
|
123
|
+
content=content,
|
|
124
|
+
is_published=is_published,
|
|
125
|
+
published_at=published_at,
|
|
126
|
+
user_id=user_id,
|
|
127
|
+
)
|
|
128
|
+
db_session.add(page)
|
|
129
|
+
await db_session.commit()
|
|
130
|
+
await db_session.refresh(page)
|
|
131
|
+
return page
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def update_page(
|
|
135
|
+
db_session: AsyncSession,
|
|
136
|
+
page_id: UUID,
|
|
137
|
+
slug: str | None = None,
|
|
138
|
+
title: str | None = None,
|
|
139
|
+
content: str | None = None,
|
|
140
|
+
is_published: bool | None = None,
|
|
141
|
+
published_at: datetime | None = None,
|
|
142
|
+
) -> Page | None:
|
|
143
|
+
"""Update an existing page.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
db_session: Database session
|
|
147
|
+
page_id: Page UUID to update
|
|
148
|
+
slug: New slug (optional)
|
|
149
|
+
title: New title (optional)
|
|
150
|
+
content: New content (optional)
|
|
151
|
+
is_published: New published status (optional)
|
|
152
|
+
published_at: New publication timestamp (optional)
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Updated Page object or None if not found
|
|
156
|
+
"""
|
|
157
|
+
page = await get_page_by_id(db_session, page_id)
|
|
158
|
+
if not page:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
if slug is not None:
|
|
162
|
+
page.slug = slug
|
|
163
|
+
if title is not None:
|
|
164
|
+
page.title = title
|
|
165
|
+
if content is not None:
|
|
166
|
+
page.content = content
|
|
167
|
+
if is_published is not None:
|
|
168
|
+
page.is_published = is_published
|
|
169
|
+
if published_at is not None:
|
|
170
|
+
page.published_at = published_at
|
|
171
|
+
|
|
172
|
+
await db_session.commit()
|
|
173
|
+
await db_session.refresh(page)
|
|
174
|
+
return page
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def delete_page(
|
|
178
|
+
db_session: AsyncSession,
|
|
179
|
+
page_id: UUID,
|
|
180
|
+
) -> bool:
|
|
181
|
+
"""Delete a page.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
db_session: Database session
|
|
185
|
+
page_id: Page UUID to delete
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
True if deleted, False if not found
|
|
189
|
+
"""
|
|
190
|
+
page = await get_page_by_id(db_session, page_id)
|
|
191
|
+
if not page:
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
await db_session.delete(page)
|
|
195
|
+
await db_session.commit()
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def check_page_ownership(
|
|
200
|
+
db_session: AsyncSession,
|
|
201
|
+
page_id: UUID,
|
|
202
|
+
user_id: UUID,
|
|
203
|
+
) -> bool:
|
|
204
|
+
"""Check if a user owns a page.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
db_session: Database session
|
|
208
|
+
page_id: Page UUID to check
|
|
209
|
+
user_id: User UUID to check ownership
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
True if user owns the page, False otherwise
|
|
213
|
+
"""
|
|
214
|
+
page = await get_page_by_id(db_session, page_id)
|
|
215
|
+
if not page:
|
|
216
|
+
return False
|
|
217
|
+
return page.user_id == user_id
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Setting service for CRUD operations on site settings."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import select
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
|
|
6
|
+
from skrift.db.models import Setting
|
|
7
|
+
|
|
8
|
+
# In-memory cache for site settings (avoids DB queries on every page render)
|
|
9
|
+
_site_settings_cache: dict[str, str] = {}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def get_setting(
|
|
13
|
+
db_session: AsyncSession,
|
|
14
|
+
key: str,
|
|
15
|
+
) -> str | None:
|
|
16
|
+
"""Get a setting value by key.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
db_session: Database session
|
|
20
|
+
key: Setting key
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Setting value or None if not found
|
|
24
|
+
"""
|
|
25
|
+
result = await db_session.execute(select(Setting).where(Setting.key == key))
|
|
26
|
+
setting = result.scalar_one_or_none()
|
|
27
|
+
return setting.value if setting else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def get_setting_with_default(
|
|
31
|
+
db_session: AsyncSession,
|
|
32
|
+
key: str,
|
|
33
|
+
default: str,
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Get a setting value by key, returning a default if not found.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
db_session: Database session
|
|
39
|
+
key: Setting key
|
|
40
|
+
default: Default value if setting doesn't exist
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Setting value or default
|
|
44
|
+
"""
|
|
45
|
+
value = await get_setting(db_session, key)
|
|
46
|
+
return value if value is not None else default
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def get_settings(
|
|
50
|
+
db_session: AsyncSession,
|
|
51
|
+
keys: list[str] | None = None,
|
|
52
|
+
) -> dict[str, str | None]:
|
|
53
|
+
"""Get multiple settings as a dictionary.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
db_session: Database session
|
|
57
|
+
keys: Optional list of keys to retrieve. If None, returns all settings.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Dictionary of key-value pairs
|
|
61
|
+
"""
|
|
62
|
+
query = select(Setting)
|
|
63
|
+
if keys:
|
|
64
|
+
query = query.where(Setting.key.in_(keys))
|
|
65
|
+
|
|
66
|
+
result = await db_session.execute(query)
|
|
67
|
+
settings = result.scalars().all()
|
|
68
|
+
return {s.key: s.value for s in settings}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def set_setting(
|
|
72
|
+
db_session: AsyncSession,
|
|
73
|
+
key: str,
|
|
74
|
+
value: str | None,
|
|
75
|
+
) -> Setting:
|
|
76
|
+
"""Set a setting value, creating or updating as needed.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
db_session: Database session
|
|
80
|
+
key: Setting key
|
|
81
|
+
value: Setting value (can be None)
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
The created or updated Setting object
|
|
85
|
+
"""
|
|
86
|
+
result = await db_session.execute(select(Setting).where(Setting.key == key))
|
|
87
|
+
setting = result.scalar_one_or_none()
|
|
88
|
+
|
|
89
|
+
if setting:
|
|
90
|
+
setting.value = value
|
|
91
|
+
else:
|
|
92
|
+
setting = Setting(key=key, value=value)
|
|
93
|
+
db_session.add(setting)
|
|
94
|
+
|
|
95
|
+
await db_session.commit()
|
|
96
|
+
await db_session.refresh(setting)
|
|
97
|
+
return setting
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def delete_setting(
|
|
101
|
+
db_session: AsyncSession,
|
|
102
|
+
key: str,
|
|
103
|
+
) -> bool:
|
|
104
|
+
"""Delete a setting by key.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
db_session: Database session
|
|
108
|
+
key: Setting key to delete
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
True if deleted, False if not found
|
|
112
|
+
"""
|
|
113
|
+
result = await db_session.execute(select(Setting).where(Setting.key == key))
|
|
114
|
+
setting = result.scalar_one_or_none()
|
|
115
|
+
|
|
116
|
+
if not setting:
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
await db_session.delete(setting)
|
|
120
|
+
await db_session.commit()
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# Site setting keys
|
|
125
|
+
SITE_NAME_KEY = "site_name"
|
|
126
|
+
SITE_TAGLINE_KEY = "site_tagline"
|
|
127
|
+
SITE_COPYRIGHT_HOLDER_KEY = "site_copyright_holder"
|
|
128
|
+
SITE_COPYRIGHT_START_YEAR_KEY = "site_copyright_start_year"
|
|
129
|
+
|
|
130
|
+
# Setup wizard key
|
|
131
|
+
SETUP_COMPLETED_AT_KEY = "setup_completed_at"
|
|
132
|
+
|
|
133
|
+
# Default values
|
|
134
|
+
SITE_DEFAULTS = {
|
|
135
|
+
SITE_NAME_KEY: "My Site",
|
|
136
|
+
SITE_TAGLINE_KEY: "Welcome to my site",
|
|
137
|
+
SITE_COPYRIGHT_HOLDER_KEY: "",
|
|
138
|
+
SITE_COPYRIGHT_START_YEAR_KEY: "",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def get_site_settings(db_session: AsyncSession) -> dict[str, str]:
|
|
143
|
+
"""Get all site settings with defaults applied.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
db_session: Database session
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Dictionary with site settings, using defaults for missing values
|
|
150
|
+
"""
|
|
151
|
+
keys = list(SITE_DEFAULTS.keys())
|
|
152
|
+
settings = await get_settings(db_session, keys)
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
key: settings.get(key) or default
|
|
156
|
+
for key, default in SITE_DEFAULTS.items()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def load_site_settings_cache(db_session: AsyncSession) -> None:
|
|
161
|
+
"""Load site settings into the in-memory cache.
|
|
162
|
+
|
|
163
|
+
Call this on application startup to populate the cache.
|
|
164
|
+
If the settings table doesn't exist yet (before migration), uses defaults.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
db_session: Database session
|
|
168
|
+
"""
|
|
169
|
+
global _site_settings_cache
|
|
170
|
+
try:
|
|
171
|
+
_site_settings_cache = await get_site_settings(db_session)
|
|
172
|
+
except Exception:
|
|
173
|
+
# Table might not exist yet (before migration), use defaults
|
|
174
|
+
_site_settings_cache = SITE_DEFAULTS.copy()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def invalidate_site_settings_cache() -> None:
|
|
178
|
+
"""Clear the site settings cache.
|
|
179
|
+
|
|
180
|
+
Call this when settings are modified to ensure fresh values are loaded.
|
|
181
|
+
"""
|
|
182
|
+
global _site_settings_cache
|
|
183
|
+
_site_settings_cache.clear()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def get_cached_site_name() -> str:
|
|
187
|
+
"""Get the cached site name for use in templates."""
|
|
188
|
+
return _site_settings_cache.get(SITE_NAME_KEY, SITE_DEFAULTS[SITE_NAME_KEY])
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def get_cached_site_tagline() -> str:
|
|
192
|
+
"""Get the cached site tagline for use in templates."""
|
|
193
|
+
return _site_settings_cache.get(SITE_TAGLINE_KEY, SITE_DEFAULTS[SITE_TAGLINE_KEY])
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def get_cached_site_copyright_holder() -> str:
|
|
197
|
+
"""Get the cached site copyright holder for use in templates."""
|
|
198
|
+
return _site_settings_cache.get(SITE_COPYRIGHT_HOLDER_KEY, SITE_DEFAULTS[SITE_COPYRIGHT_HOLDER_KEY])
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def get_cached_site_copyright_start_year() -> str | int | None:
|
|
202
|
+
"""Get the cached site copyright start year for use in templates."""
|
|
203
|
+
value = _site_settings_cache.get(SITE_COPYRIGHT_START_YEAR_KEY, SITE_DEFAULTS[SITE_COPYRIGHT_START_YEAR_KEY])
|
|
204
|
+
if value and value.isdigit():
|
|
205
|
+
return int(value)
|
|
206
|
+
return None
|
skrift/lib/__init__.py
ADDED
skrift/lib/exceptions.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from litestar import Request, Response
|
|
5
|
+
from litestar.exceptions import HTTPException
|
|
6
|
+
from litestar.status_codes import HTTP_500_INTERNAL_SERVER_ERROR
|
|
7
|
+
|
|
8
|
+
from skrift.config import get_settings
|
|
9
|
+
from skrift.db.services.setting_service import get_cached_site_name
|
|
10
|
+
|
|
11
|
+
TEMPLATE_DIR = Path(__file__).parent.parent.parent / "templates"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _accepts_html(request: Request) -> bool:
|
|
15
|
+
"""Check if the request accepts HTML responses (browser request)."""
|
|
16
|
+
accept = request.headers.get("accept", "")
|
|
17
|
+
return "text/html" in accept
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _resolve_error_template(status_code: int) -> str:
|
|
21
|
+
"""Resolve error template with fallback, WP-style."""
|
|
22
|
+
specific_template = f"error-{status_code}.html"
|
|
23
|
+
if (TEMPLATE_DIR / specific_template).exists():
|
|
24
|
+
return specific_template
|
|
25
|
+
return "error.html"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_session_from_cookie(request: Request) -> dict | None:
|
|
29
|
+
"""Manually decode session from cookie when middleware hasn't run.
|
|
30
|
+
|
|
31
|
+
This replicates Litestar's ClientSideSessionBackend decryption logic synchronously.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
import time
|
|
35
|
+
from base64 import b64decode
|
|
36
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
37
|
+
from litestar.middleware.session.client_side import decode_json, NONCE_SIZE, AAD
|
|
38
|
+
|
|
39
|
+
# Get the session cookie
|
|
40
|
+
cookie_value = request.cookies.get("session")
|
|
41
|
+
if not cookie_value:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
# Get the secret key and hash it the same way asgi.py does
|
|
45
|
+
settings = get_settings()
|
|
46
|
+
secret = hashlib.sha256(settings.secret_key.encode()).digest()
|
|
47
|
+
|
|
48
|
+
# Decode the base64 cookie value
|
|
49
|
+
decoded = b64decode(cookie_value)
|
|
50
|
+
|
|
51
|
+
# Extract nonce (first 12 bytes)
|
|
52
|
+
nonce = decoded[:NONCE_SIZE]
|
|
53
|
+
|
|
54
|
+
# Find where AAD marker starts
|
|
55
|
+
aad_starts_from = decoded.find(AAD)
|
|
56
|
+
if aad_starts_from == -1:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
# Associated data is the JSON part only (after removing the AAD prefix)
|
|
60
|
+
associated_data = decoded[aad_starts_from:].replace(AAD, b"")
|
|
61
|
+
|
|
62
|
+
# Check expiration
|
|
63
|
+
if decode_json(value=associated_data)["expires_at"] <= round(time.time()):
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
# Extract encrypted session (between nonce and AAD marker)
|
|
67
|
+
encrypted_session = decoded[NONCE_SIZE:aad_starts_from]
|
|
68
|
+
|
|
69
|
+
# Decrypt using AES-GCM with the JSON part as associated_data
|
|
70
|
+
aesgcm = AESGCM(secret)
|
|
71
|
+
decrypted = aesgcm.decrypt(nonce, encrypted_session, associated_data=associated_data)
|
|
72
|
+
|
|
73
|
+
# Deserialize JSON
|
|
74
|
+
session_data = decode_json(decrypted)
|
|
75
|
+
return session_data
|
|
76
|
+
except Exception:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _get_user_context_from_session(session: dict | None) -> dict | None:
|
|
81
|
+
"""Get user display info from session if available."""
|
|
82
|
+
if not session:
|
|
83
|
+
return None
|
|
84
|
+
try:
|
|
85
|
+
user_id = session.get("user_id")
|
|
86
|
+
if not user_id:
|
|
87
|
+
return None
|
|
88
|
+
return {
|
|
89
|
+
"id": user_id,
|
|
90
|
+
"name": session.get("user_name"),
|
|
91
|
+
"email": session.get("user_email"),
|
|
92
|
+
"picture_url": session.get("user_picture_url"),
|
|
93
|
+
}
|
|
94
|
+
except Exception:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class SessionUser:
|
|
99
|
+
"""Lightweight user object for templates, populated from session data."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, data: dict):
|
|
102
|
+
self.id = data.get("id")
|
|
103
|
+
self.name = data.get("name")
|
|
104
|
+
self.email = data.get("email")
|
|
105
|
+
self.picture_url = data.get("picture_url")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def http_exception_handler(request: Request, exc: HTTPException) -> Response:
|
|
109
|
+
"""Handle HTTP exceptions with HTML for browsers, JSON for APIs."""
|
|
110
|
+
status_code = exc.status_code
|
|
111
|
+
detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
|
|
112
|
+
|
|
113
|
+
if _accepts_html(request):
|
|
114
|
+
session = _get_session_from_cookie(request)
|
|
115
|
+
user_data = _get_user_context_from_session(session)
|
|
116
|
+
user = SessionUser(user_data) if user_data else None
|
|
117
|
+
template_name = _resolve_error_template(status_code)
|
|
118
|
+
template_engine = request.app.template_engine
|
|
119
|
+
template = template_engine.get_template(template_name)
|
|
120
|
+
content = template.render(
|
|
121
|
+
status_code=status_code,
|
|
122
|
+
message=detail,
|
|
123
|
+
user=user,
|
|
124
|
+
site_name=get_cached_site_name,
|
|
125
|
+
)
|
|
126
|
+
return Response(
|
|
127
|
+
content=content,
|
|
128
|
+
status_code=status_code,
|
|
129
|
+
media_type="text/html",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# JSON response for API clients
|
|
133
|
+
return Response(
|
|
134
|
+
content={"status_code": status_code, "detail": detail},
|
|
135
|
+
status_code=status_code,
|
|
136
|
+
media_type="application/json",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def internal_server_error_handler(request: Request, exc: Exception) -> Response:
|
|
141
|
+
"""Handle unexpected exceptions with HTML for browsers, JSON for APIs."""
|
|
142
|
+
status_code = HTTP_500_INTERNAL_SERVER_ERROR
|
|
143
|
+
|
|
144
|
+
if _accepts_html(request):
|
|
145
|
+
session = _get_session_from_cookie(request)
|
|
146
|
+
user_data = _get_user_context_from_session(session)
|
|
147
|
+
user = SessionUser(user_data) if user_data else None
|
|
148
|
+
template_name = _resolve_error_template(status_code)
|
|
149
|
+
template_engine = request.app.template_engine
|
|
150
|
+
template = template_engine.get_template(template_name)
|
|
151
|
+
content = template.render(
|
|
152
|
+
status_code=status_code,
|
|
153
|
+
message="An unexpected error occurred.",
|
|
154
|
+
user=user,
|
|
155
|
+
site_name=get_cached_site_name,
|
|
156
|
+
)
|
|
157
|
+
return Response(
|
|
158
|
+
content=content,
|
|
159
|
+
status_code=status_code,
|
|
160
|
+
media_type="text/html",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# JSON response for API clients
|
|
164
|
+
return Response(
|
|
165
|
+
content={"status_code": status_code, "detail": "Internal Server Error"},
|
|
166
|
+
status_code=status_code,
|
|
167
|
+
media_type="application/json",
|
|
168
|
+
)
|
skrift/lib/template.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from litestar.contrib.jinja import JinjaTemplateEngine
|
|
6
|
+
from litestar.response import Template as TemplateResponse
|
|
7
|
+
from litestar.template import TemplateConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Template:
|
|
11
|
+
"""WordPress-like template resolver with fallback support.
|
|
12
|
+
|
|
13
|
+
Resolves templates in order of specificity:
|
|
14
|
+
- Template("post", "about") → tries post-about.html, falls back to post.html
|
|
15
|
+
- Template("page", "services", "web") → tries page-services-web.html → page-services.html → page.html
|
|
16
|
+
|
|
17
|
+
Template Directory Hierarchy:
|
|
18
|
+
Templates are searched in the following order:
|
|
19
|
+
1. ./templates/ (working directory) - User overrides
|
|
20
|
+
2. skrift/templates/ (package directory) - Default templates
|
|
21
|
+
|
|
22
|
+
Available Templates for Override:
|
|
23
|
+
- base.html - Base layout template
|
|
24
|
+
- index.html - Homepage template
|
|
25
|
+
- page.html - Default page template
|
|
26
|
+
- post.html - Default post template
|
|
27
|
+
- error.html - Generic error page
|
|
28
|
+
- error-404.html - Not found error page
|
|
29
|
+
- error-500.html - Server error page
|
|
30
|
+
|
|
31
|
+
Users can override any template by creating a file with the same name
|
|
32
|
+
in their project's ./templates/ directory.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, template_type: str, *slugs: str, context: dict[str, Any] | None = None):
|
|
36
|
+
self.template_type = template_type
|
|
37
|
+
self.slugs = slugs
|
|
38
|
+
self.context = context or {}
|
|
39
|
+
self._resolved_template: str | None = None
|
|
40
|
+
|
|
41
|
+
def resolve(self, template_dir: Path) -> str:
|
|
42
|
+
"""Resolve the most specific template that exists.
|
|
43
|
+
|
|
44
|
+
Searches for templates in order:
|
|
45
|
+
1. Working directory's ./templates/
|
|
46
|
+
2. Package's templates directory
|
|
47
|
+
|
|
48
|
+
Within each directory, searches from most to least specific template name.
|
|
49
|
+
"""
|
|
50
|
+
if self._resolved_template:
|
|
51
|
+
return self._resolved_template
|
|
52
|
+
|
|
53
|
+
# Define search paths: working directory first, then package directory
|
|
54
|
+
working_dir_templates = Path(os.getcwd()) / "templates"
|
|
55
|
+
search_dirs = [working_dir_templates, template_dir]
|
|
56
|
+
|
|
57
|
+
# Build list of templates to try, from most to least specific
|
|
58
|
+
templates_to_try = []
|
|
59
|
+
|
|
60
|
+
if self.slugs:
|
|
61
|
+
# Add progressively less specific templates
|
|
62
|
+
for i in range(len(self.slugs), 0, -1):
|
|
63
|
+
slug_part = "-".join(self.slugs[:i])
|
|
64
|
+
templates_to_try.append(f"{self.template_type}-{slug_part}.html")
|
|
65
|
+
|
|
66
|
+
# Always fall back to the base template type
|
|
67
|
+
templates_to_try.append(f"{self.template_type}.html")
|
|
68
|
+
|
|
69
|
+
# Search for templates in each directory
|
|
70
|
+
for template_name in templates_to_try:
|
|
71
|
+
for search_dir in search_dirs:
|
|
72
|
+
template_path = search_dir / template_name
|
|
73
|
+
if template_path.exists():
|
|
74
|
+
self._resolved_template = template_name
|
|
75
|
+
return template_name
|
|
76
|
+
|
|
77
|
+
# Default to base template even if it doesn't exist (let Jinja handle the error)
|
|
78
|
+
self._resolved_template = f"{self.template_type}.html"
|
|
79
|
+
return self._resolved_template
|
|
80
|
+
|
|
81
|
+
def render(self, template_dir: Path, **extra_context: Any) -> TemplateResponse:
|
|
82
|
+
"""Resolve template and return TemplateResponse with merged context.
|
|
83
|
+
|
|
84
|
+
Context passed to __init__ is merged with extra_context, with extra_context
|
|
85
|
+
taking precedence for duplicate keys.
|
|
86
|
+
"""
|
|
87
|
+
template_name = self.resolve(template_dir)
|
|
88
|
+
merged_context = {**self.context, **extra_context}
|
|
89
|
+
return TemplateResponse(template_name, context=merged_context)
|
|
90
|
+
|
|
91
|
+
def __repr__(self) -> str:
|
|
92
|
+
return f"Template({self.template_type!r}, {', '.join(repr(s) for s in self.slugs)})"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_template_config(template_dir: Path) -> TemplateConfig:
|
|
96
|
+
"""Get the Jinja template configuration.
|
|
97
|
+
|
|
98
|
+
Configures Jinja to search for templates in multiple directories:
|
|
99
|
+
1. ./templates/ (working directory) - for user overrides
|
|
100
|
+
2. package templates directory - for default templates
|
|
101
|
+
"""
|
|
102
|
+
working_dir_templates = Path(os.getcwd()) / "templates"
|
|
103
|
+
directories = [working_dir_templates, template_dir]
|
|
104
|
+
|
|
105
|
+
return TemplateConfig(
|
|
106
|
+
directory=directories,
|
|
107
|
+
engine=JinjaTemplateEngine,
|
|
108
|
+
)
|