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,371 @@
|
|
|
1
|
+
"""Authentication controller for OAuth login flows.
|
|
2
|
+
|
|
3
|
+
Supports multiple OAuth providers: Google, GitHub, Microsoft, Discord, Facebook, X (Twitter).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import hashlib
|
|
8
|
+
import secrets
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
from urllib.parse import urlencode
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from litestar import Controller, Request, get
|
|
15
|
+
from litestar.exceptions import HTTPException, NotFoundException
|
|
16
|
+
from litestar.params import Parameter
|
|
17
|
+
from litestar.response import Redirect, Template as TemplateResponse
|
|
18
|
+
from sqlalchemy import select
|
|
19
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
20
|
+
|
|
21
|
+
from skrift.config import get_settings
|
|
22
|
+
from skrift.db.models.user import User
|
|
23
|
+
from skrift.setup.providers import OAUTH_PROVIDERS, get_provider_info
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_auth_url(provider: str, settings, state: str, code_challenge: str | None = None) -> str:
|
|
27
|
+
"""Build the OAuth authorization URL for a provider."""
|
|
28
|
+
provider_info = get_provider_info(provider)
|
|
29
|
+
if not provider_info:
|
|
30
|
+
raise ValueError(f"Unknown provider: {provider}")
|
|
31
|
+
|
|
32
|
+
provider_config = settings.auth.providers.get(provider)
|
|
33
|
+
if not provider_config:
|
|
34
|
+
raise ValueError(f"Provider {provider} not configured")
|
|
35
|
+
|
|
36
|
+
# Build auth URL (handle Microsoft tenant placeholder)
|
|
37
|
+
auth_url = provider_info.auth_url
|
|
38
|
+
if "{tenant}" in auth_url:
|
|
39
|
+
tenant = getattr(provider_config, "tenant_id", None) or "common"
|
|
40
|
+
auth_url = auth_url.replace("{tenant}", tenant)
|
|
41
|
+
|
|
42
|
+
params = {
|
|
43
|
+
"client_id": provider_config.client_id,
|
|
44
|
+
"redirect_uri": settings.auth.get_redirect_uri(provider),
|
|
45
|
+
"response_type": "code",
|
|
46
|
+
"scope": " ".join(provider_config.scopes),
|
|
47
|
+
"state": state,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Provider-specific parameters
|
|
51
|
+
if provider == "google":
|
|
52
|
+
params["access_type"] = "offline"
|
|
53
|
+
params["prompt"] = "select_account"
|
|
54
|
+
elif provider == "twitter":
|
|
55
|
+
# Twitter requires PKCE
|
|
56
|
+
if code_challenge:
|
|
57
|
+
params["code_challenge"] = code_challenge
|
|
58
|
+
params["code_challenge_method"] = "S256"
|
|
59
|
+
elif provider == "discord":
|
|
60
|
+
params["prompt"] = "consent"
|
|
61
|
+
|
|
62
|
+
return f"{auth_url}?{urlencode(params)}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def exchange_code_for_token(
|
|
66
|
+
provider: str, settings, code: str, code_verifier: str | None = None
|
|
67
|
+
) -> dict:
|
|
68
|
+
"""Exchange authorization code for access token."""
|
|
69
|
+
provider_info = get_provider_info(provider)
|
|
70
|
+
if not provider_info:
|
|
71
|
+
raise ValueError(f"Unknown provider: {provider}")
|
|
72
|
+
|
|
73
|
+
provider_config = settings.auth.providers.get(provider)
|
|
74
|
+
if not provider_config:
|
|
75
|
+
raise ValueError(f"Provider {provider} not configured")
|
|
76
|
+
|
|
77
|
+
# Build token URL (handle Microsoft tenant placeholder)
|
|
78
|
+
token_url = provider_info.token_url
|
|
79
|
+
if "{tenant}" in token_url:
|
|
80
|
+
tenant = getattr(provider_config, "tenant_id", None) or "common"
|
|
81
|
+
token_url = token_url.replace("{tenant}", tenant)
|
|
82
|
+
|
|
83
|
+
data = {
|
|
84
|
+
"client_id": provider_config.client_id,
|
|
85
|
+
"client_secret": provider_config.client_secret,
|
|
86
|
+
"code": code,
|
|
87
|
+
"grant_type": "authorization_code",
|
|
88
|
+
"redirect_uri": settings.auth.get_redirect_uri(provider),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# Twitter requires PKCE code_verifier
|
|
92
|
+
if provider == "twitter" and code_verifier:
|
|
93
|
+
data["code_verifier"] = code_verifier
|
|
94
|
+
|
|
95
|
+
headers = {"Accept": "application/json"}
|
|
96
|
+
|
|
97
|
+
# GitHub needs special Accept header
|
|
98
|
+
if provider == "github":
|
|
99
|
+
headers["Accept"] = "application/json"
|
|
100
|
+
|
|
101
|
+
# Twitter uses Basic auth for token exchange
|
|
102
|
+
if provider == "twitter":
|
|
103
|
+
credentials = base64.b64encode(
|
|
104
|
+
f"{provider_config.client_id}:{provider_config.client_secret}".encode()
|
|
105
|
+
).decode()
|
|
106
|
+
headers["Authorization"] = f"Basic {credentials}"
|
|
107
|
+
del data["client_secret"]
|
|
108
|
+
|
|
109
|
+
async with httpx.AsyncClient() as client:
|
|
110
|
+
response = await client.post(token_url, data=data, headers=headers)
|
|
111
|
+
|
|
112
|
+
if response.status_code != 200:
|
|
113
|
+
raise HTTPException(
|
|
114
|
+
status_code=400,
|
|
115
|
+
detail=f"Failed to exchange code for tokens: {response.text}",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return response.json()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def fetch_user_info(provider: str, access_token: str) -> dict:
|
|
122
|
+
"""Fetch user information from the OAuth provider."""
|
|
123
|
+
provider_info = get_provider_info(provider)
|
|
124
|
+
if not provider_info:
|
|
125
|
+
raise ValueError(f"Unknown provider: {provider}")
|
|
126
|
+
|
|
127
|
+
headers = {"Authorization": f"Bearer {access_token}"}
|
|
128
|
+
|
|
129
|
+
async with httpx.AsyncClient() as client:
|
|
130
|
+
response = await client.get(provider_info.userinfo_url, headers=headers)
|
|
131
|
+
|
|
132
|
+
if response.status_code != 200:
|
|
133
|
+
raise HTTPException(status_code=400, detail="Failed to fetch user info")
|
|
134
|
+
|
|
135
|
+
user_info = response.json()
|
|
136
|
+
|
|
137
|
+
# GitHub requires separate email fetch if email is private
|
|
138
|
+
if provider == "github" and not user_info.get("email"):
|
|
139
|
+
email_response = await client.get(
|
|
140
|
+
"https://api.github.com/user/emails", headers=headers
|
|
141
|
+
)
|
|
142
|
+
if email_response.status_code == 200:
|
|
143
|
+
emails = email_response.json()
|
|
144
|
+
primary_email = next(
|
|
145
|
+
(e["email"] for e in emails if e.get("primary")), None
|
|
146
|
+
)
|
|
147
|
+
if primary_email:
|
|
148
|
+
user_info["email"] = primary_email
|
|
149
|
+
|
|
150
|
+
# Twitter has different structure
|
|
151
|
+
if provider == "twitter":
|
|
152
|
+
data = user_info.get("data", {})
|
|
153
|
+
user_info = {
|
|
154
|
+
"id": data.get("id"),
|
|
155
|
+
"name": data.get("name"),
|
|
156
|
+
"username": data.get("username"),
|
|
157
|
+
"email": None, # Twitter OAuth 2.0 doesn't provide email by default
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return user_info
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def extract_user_data(provider: str, user_info: dict) -> dict:
|
|
164
|
+
"""Extract normalized user data from provider-specific response."""
|
|
165
|
+
if provider == "google":
|
|
166
|
+
return {
|
|
167
|
+
"oauth_id": user_info.get("id"),
|
|
168
|
+
"email": user_info.get("email"),
|
|
169
|
+
"name": user_info.get("name"),
|
|
170
|
+
"picture_url": user_info.get("picture"),
|
|
171
|
+
}
|
|
172
|
+
elif provider == "github":
|
|
173
|
+
return {
|
|
174
|
+
"oauth_id": str(user_info.get("id")),
|
|
175
|
+
"email": user_info.get("email"),
|
|
176
|
+
"name": user_info.get("name") or user_info.get("login"),
|
|
177
|
+
"picture_url": user_info.get("avatar_url"),
|
|
178
|
+
}
|
|
179
|
+
elif provider == "microsoft":
|
|
180
|
+
return {
|
|
181
|
+
"oauth_id": user_info.get("id"),
|
|
182
|
+
"email": user_info.get("mail") or user_info.get("userPrincipalName"),
|
|
183
|
+
"name": user_info.get("displayName"),
|
|
184
|
+
"picture_url": None, # Microsoft Graph requires separate call for photo
|
|
185
|
+
}
|
|
186
|
+
elif provider == "discord":
|
|
187
|
+
avatar = user_info.get("avatar")
|
|
188
|
+
user_id = user_info.get("id")
|
|
189
|
+
avatar_url = None
|
|
190
|
+
if avatar and user_id:
|
|
191
|
+
avatar_url = f"https://cdn.discordapp.com/avatars/{user_id}/{avatar}.png"
|
|
192
|
+
return {
|
|
193
|
+
"oauth_id": user_id,
|
|
194
|
+
"email": user_info.get("email"),
|
|
195
|
+
"name": user_info.get("global_name") or user_info.get("username"),
|
|
196
|
+
"picture_url": avatar_url,
|
|
197
|
+
}
|
|
198
|
+
elif provider == "facebook":
|
|
199
|
+
picture = user_info.get("picture", {}).get("data", {})
|
|
200
|
+
return {
|
|
201
|
+
"oauth_id": user_info.get("id"),
|
|
202
|
+
"email": user_info.get("email"),
|
|
203
|
+
"name": user_info.get("name"),
|
|
204
|
+
"picture_url": picture.get("url") if not picture.get("is_silhouette") else None,
|
|
205
|
+
}
|
|
206
|
+
elif provider == "twitter":
|
|
207
|
+
return {
|
|
208
|
+
"oauth_id": user_info.get("id"),
|
|
209
|
+
"email": user_info.get("email"),
|
|
210
|
+
"name": user_info.get("name") or user_info.get("username"),
|
|
211
|
+
"picture_url": None,
|
|
212
|
+
}
|
|
213
|
+
else:
|
|
214
|
+
return {
|
|
215
|
+
"oauth_id": str(user_info.get("id", user_info.get("sub"))),
|
|
216
|
+
"email": user_info.get("email"),
|
|
217
|
+
"name": user_info.get("name"),
|
|
218
|
+
"picture_url": user_info.get("picture"),
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class AuthController(Controller):
|
|
223
|
+
path = "/auth"
|
|
224
|
+
|
|
225
|
+
@get("/{provider:str}/login")
|
|
226
|
+
async def oauth_login(
|
|
227
|
+
self,
|
|
228
|
+
request: Request,
|
|
229
|
+
provider: str,
|
|
230
|
+
) -> Redirect:
|
|
231
|
+
"""Redirect to OAuth provider consent screen."""
|
|
232
|
+
settings = get_settings()
|
|
233
|
+
provider_info = get_provider_info(provider)
|
|
234
|
+
|
|
235
|
+
if not provider_info:
|
|
236
|
+
raise NotFoundException(f"Unknown provider: {provider}")
|
|
237
|
+
|
|
238
|
+
if provider not in settings.auth.providers:
|
|
239
|
+
raise NotFoundException(f"Provider {provider} not configured")
|
|
240
|
+
|
|
241
|
+
# Generate CSRF state token
|
|
242
|
+
state = secrets.token_urlsafe(32)
|
|
243
|
+
request.session["oauth_state"] = state
|
|
244
|
+
request.session["oauth_provider"] = provider
|
|
245
|
+
|
|
246
|
+
# Generate PKCE for Twitter
|
|
247
|
+
code_challenge = None
|
|
248
|
+
if provider == "twitter":
|
|
249
|
+
code_verifier = secrets.token_urlsafe(64)[:128]
|
|
250
|
+
request.session["oauth_code_verifier"] = code_verifier
|
|
251
|
+
# S256 challenge
|
|
252
|
+
code_challenge = base64.urlsafe_b64encode(
|
|
253
|
+
hashlib.sha256(code_verifier.encode()).digest()
|
|
254
|
+
).decode().rstrip("=")
|
|
255
|
+
|
|
256
|
+
auth_url = get_auth_url(provider, settings, state, code_challenge)
|
|
257
|
+
return Redirect(path=auth_url)
|
|
258
|
+
|
|
259
|
+
@get("/{provider:str}/callback")
|
|
260
|
+
async def oauth_callback(
|
|
261
|
+
self,
|
|
262
|
+
request: Request,
|
|
263
|
+
db_session: AsyncSession,
|
|
264
|
+
provider: str,
|
|
265
|
+
code: str | None = None,
|
|
266
|
+
oauth_state: Annotated[str | None, Parameter(query="state")] = None,
|
|
267
|
+
error: str | None = None,
|
|
268
|
+
) -> Redirect:
|
|
269
|
+
"""Handle OAuth callback from provider."""
|
|
270
|
+
settings = get_settings()
|
|
271
|
+
provider_info = get_provider_info(provider)
|
|
272
|
+
|
|
273
|
+
if not provider_info:
|
|
274
|
+
raise NotFoundException(f"Unknown provider: {provider}")
|
|
275
|
+
|
|
276
|
+
# Check for OAuth errors
|
|
277
|
+
if error:
|
|
278
|
+
request.session["flash"] = f"OAuth error: {error}"
|
|
279
|
+
return Redirect(path="/auth/login")
|
|
280
|
+
|
|
281
|
+
# Verify CSRF state
|
|
282
|
+
stored_state = request.session.pop("oauth_state", None)
|
|
283
|
+
if not oauth_state or oauth_state != stored_state:
|
|
284
|
+
raise HTTPException(status_code=400, detail="Invalid OAuth state")
|
|
285
|
+
|
|
286
|
+
if not code:
|
|
287
|
+
raise HTTPException(status_code=400, detail="Missing authorization code")
|
|
288
|
+
|
|
289
|
+
# Get PKCE verifier if present (for Twitter)
|
|
290
|
+
code_verifier = request.session.pop("oauth_code_verifier", None)
|
|
291
|
+
|
|
292
|
+
# Exchange code for tokens
|
|
293
|
+
tokens = await exchange_code_for_token(
|
|
294
|
+
provider, settings, code, code_verifier
|
|
295
|
+
)
|
|
296
|
+
access_token = tokens.get("access_token")
|
|
297
|
+
|
|
298
|
+
if not access_token:
|
|
299
|
+
raise HTTPException(status_code=400, detail="No access token received")
|
|
300
|
+
|
|
301
|
+
# Fetch user info
|
|
302
|
+
user_info = await fetch_user_info(provider, access_token)
|
|
303
|
+
user_data = extract_user_data(provider, user_info)
|
|
304
|
+
|
|
305
|
+
oauth_id = user_data["oauth_id"]
|
|
306
|
+
if not oauth_id:
|
|
307
|
+
raise HTTPException(status_code=400, detail="Could not determine user ID")
|
|
308
|
+
|
|
309
|
+
# Find or create user
|
|
310
|
+
result = await db_session.execute(
|
|
311
|
+
select(User).where(User.oauth_id == oauth_id, User.oauth_provider == provider)
|
|
312
|
+
)
|
|
313
|
+
user = result.scalar_one_or_none()
|
|
314
|
+
|
|
315
|
+
if user:
|
|
316
|
+
# Update existing user
|
|
317
|
+
user.name = user_data["name"]
|
|
318
|
+
if user_data["picture_url"]:
|
|
319
|
+
user.picture_url = user_data["picture_url"]
|
|
320
|
+
user.last_login_at = datetime.now(UTC)
|
|
321
|
+
else:
|
|
322
|
+
# Create new user (admin role is only assigned through setup flow)
|
|
323
|
+
user = User(
|
|
324
|
+
oauth_provider=provider,
|
|
325
|
+
oauth_id=oauth_id,
|
|
326
|
+
email=user_data["email"],
|
|
327
|
+
name=user_data["name"],
|
|
328
|
+
picture_url=user_data["picture_url"],
|
|
329
|
+
last_login_at=datetime.now(UTC),
|
|
330
|
+
)
|
|
331
|
+
db_session.add(user)
|
|
332
|
+
await db_session.flush()
|
|
333
|
+
|
|
334
|
+
await db_session.commit()
|
|
335
|
+
|
|
336
|
+
# Set session with user info
|
|
337
|
+
request.session["user_id"] = str(user.id)
|
|
338
|
+
request.session["user_name"] = user.name
|
|
339
|
+
request.session["user_email"] = user.email
|
|
340
|
+
request.session["user_picture_url"] = user.picture_url
|
|
341
|
+
request.session["flash"] = "Successfully logged in!"
|
|
342
|
+
|
|
343
|
+
return Redirect(path="/")
|
|
344
|
+
|
|
345
|
+
@get("/login")
|
|
346
|
+
async def login_page(self, request: Request) -> TemplateResponse:
|
|
347
|
+
"""Show login page with available providers."""
|
|
348
|
+
flash = request.session.pop("flash", None)
|
|
349
|
+
settings = get_settings()
|
|
350
|
+
|
|
351
|
+
# Get configured providers
|
|
352
|
+
configured_providers = list(settings.auth.providers.keys())
|
|
353
|
+
providers = {
|
|
354
|
+
key: OAUTH_PROVIDERS[key]
|
|
355
|
+
for key in configured_providers
|
|
356
|
+
if key in OAUTH_PROVIDERS
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return TemplateResponse(
|
|
360
|
+
"auth/login.html",
|
|
361
|
+
context={
|
|
362
|
+
"flash": flash,
|
|
363
|
+
"providers": providers,
|
|
364
|
+
},
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
@get("/logout")
|
|
368
|
+
async def logout(self, request: Request) -> Redirect:
|
|
369
|
+
"""Clear session and redirect to home."""
|
|
370
|
+
request.session.clear()
|
|
371
|
+
return Redirect(path="/")
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from litestar import Controller, Request, get
|
|
5
|
+
from litestar.exceptions import NotFoundException
|
|
6
|
+
from litestar.response import Template as TemplateResponse
|
|
7
|
+
from sqlalchemy import select
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
|
|
10
|
+
from skrift.db.models.user import User
|
|
11
|
+
from skrift.db.services import page_service
|
|
12
|
+
from skrift.lib.template import Template
|
|
13
|
+
|
|
14
|
+
TEMPLATE_DIR = Path(__file__).parent.parent.parent / "templates"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WebController(Controller):
|
|
18
|
+
path = "/"
|
|
19
|
+
|
|
20
|
+
async def _get_user_context(
|
|
21
|
+
self, request: "Request", db_session: AsyncSession
|
|
22
|
+
) -> dict:
|
|
23
|
+
"""Get user data for template context if logged in."""
|
|
24
|
+
user_id = request.session.get("user_id")
|
|
25
|
+
if not user_id:
|
|
26
|
+
return {"user": None}
|
|
27
|
+
|
|
28
|
+
result = await db_session.execute(select(User).where(User.id == UUID(user_id)))
|
|
29
|
+
user = result.scalar_one_or_none()
|
|
30
|
+
return {"user": user}
|
|
31
|
+
|
|
32
|
+
@get("/")
|
|
33
|
+
async def index(
|
|
34
|
+
self, request: "Request", db_session: AsyncSession
|
|
35
|
+
) -> TemplateResponse:
|
|
36
|
+
"""Home page."""
|
|
37
|
+
user_ctx = await self._get_user_context(request, db_session)
|
|
38
|
+
flash = request.session.pop("flash", None)
|
|
39
|
+
|
|
40
|
+
return TemplateResponse(
|
|
41
|
+
"index.html",
|
|
42
|
+
context={"flash": flash, **user_ctx},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@get("/{path:path}")
|
|
46
|
+
async def view_page(
|
|
47
|
+
self, request: "Request", db_session: AsyncSession, path: str
|
|
48
|
+
) -> TemplateResponse:
|
|
49
|
+
"""View a page by path with WP-like template resolution."""
|
|
50
|
+
user_ctx = await self._get_user_context(request, db_session)
|
|
51
|
+
flash = request.session.pop("flash", None)
|
|
52
|
+
|
|
53
|
+
# Split path into slugs (e.g., "services/web" -> ["services", "web"])
|
|
54
|
+
slugs = [s for s in path.split("/") if s]
|
|
55
|
+
|
|
56
|
+
# Use the full path as the slug for database lookup
|
|
57
|
+
page_slug = "/".join(slugs)
|
|
58
|
+
|
|
59
|
+
# Fetch page from database
|
|
60
|
+
page = await page_service.get_page_by_slug(
|
|
61
|
+
db_session, page_slug, published_only=not request.session.get("user_id")
|
|
62
|
+
)
|
|
63
|
+
if not page:
|
|
64
|
+
raise NotFoundException(f"Page '{path}' not found")
|
|
65
|
+
|
|
66
|
+
template = Template("page", *slugs, context={"path": path, "slugs": slugs, "page": page})
|
|
67
|
+
return template.render(TEMPLATE_DIR, flash=flash, **user_ctx)
|
skrift/db/__init__.py
ADDED
skrift/db/base.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from skrift.db.models.page import Page
|
|
2
|
+
from skrift.db.models.role import Role, RolePermission, user_roles
|
|
3
|
+
from skrift.db.models.setting import Setting
|
|
4
|
+
from skrift.db.models.user import User
|
|
5
|
+
|
|
6
|
+
__all__ = ["Page", "Role", "RolePermission", "Setting", "User", "user_roles"]
|
skrift/db/models/page.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey
|
|
5
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
6
|
+
|
|
7
|
+
from skrift.db.base import Base
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Page(Base):
|
|
11
|
+
"""Page model for content management."""
|
|
12
|
+
|
|
13
|
+
__tablename__ = "pages"
|
|
14
|
+
|
|
15
|
+
# Author relationship (optional - pages may not have an author)
|
|
16
|
+
user_id: Mapped[UUID | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
|
|
17
|
+
user: Mapped["User"] = relationship("User", back_populates="pages")
|
|
18
|
+
|
|
19
|
+
# Content fields
|
|
20
|
+
slug: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
|
|
21
|
+
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
|
22
|
+
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
23
|
+
|
|
24
|
+
# Publication fields
|
|
25
|
+
is_published: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
26
|
+
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
skrift/db/models/role.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Role and permission database models."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import Column, ForeignKey, String, Table, UniqueConstraint
|
|
7
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
8
|
+
|
|
9
|
+
from skrift.db.base import Base
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from skrift.db.models.user import User
|
|
13
|
+
|
|
14
|
+
# Association table for many-to-many relationship between users and roles
|
|
15
|
+
user_roles = Table(
|
|
16
|
+
"user_roles",
|
|
17
|
+
Base.metadata,
|
|
18
|
+
Column("user_id", ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
|
|
19
|
+
Column("role_id", ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Role(Base):
|
|
24
|
+
"""Role model for user authorization."""
|
|
25
|
+
|
|
26
|
+
__tablename__ = "roles"
|
|
27
|
+
|
|
28
|
+
name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
|
|
29
|
+
display_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
30
|
+
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
31
|
+
|
|
32
|
+
# Relationships
|
|
33
|
+
users: Mapped[list["User"]] = relationship(
|
|
34
|
+
"User", secondary=user_roles, back_populates="roles"
|
|
35
|
+
)
|
|
36
|
+
permissions: Mapped[list["RolePermission"]] = relationship(
|
|
37
|
+
"RolePermission", back_populates="role", cascade="all, delete-orphan"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RolePermission(Base):
|
|
42
|
+
"""Permission associated with a role."""
|
|
43
|
+
|
|
44
|
+
__tablename__ = "role_permissions"
|
|
45
|
+
|
|
46
|
+
role_id: Mapped[UUID] = mapped_column(
|
|
47
|
+
ForeignKey("roles.id", ondelete="CASCADE"), nullable=False
|
|
48
|
+
)
|
|
49
|
+
permission: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
50
|
+
|
|
51
|
+
# Relationships
|
|
52
|
+
role: Mapped["Role"] = relationship("Role", back_populates="permissions")
|
|
53
|
+
|
|
54
|
+
__table_args__ = (
|
|
55
|
+
UniqueConstraint("role_id", "permission", name="uq_role_permission"),
|
|
56
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from sqlalchemy import String, Text
|
|
2
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
3
|
+
|
|
4
|
+
from skrift.db.base import Base
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Setting(Base):
|
|
8
|
+
"""Key-value setting storage for site configuration."""
|
|
9
|
+
|
|
10
|
+
__tablename__ = "settings"
|
|
11
|
+
|
|
12
|
+
key: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
|
|
13
|
+
value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
skrift/db/models/user.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import String, Boolean, DateTime
|
|
5
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
6
|
+
|
|
7
|
+
from skrift.db.base import Base
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from skrift.db.models.page import Page
|
|
11
|
+
from skrift.db.models.role import Role
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class User(Base):
|
|
15
|
+
"""User model for OAuth authentication."""
|
|
16
|
+
|
|
17
|
+
__tablename__ = "users"
|
|
18
|
+
|
|
19
|
+
# OAuth identifiers
|
|
20
|
+
oauth_provider: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
21
|
+
oauth_id: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
|
22
|
+
|
|
23
|
+
# Profile data from OAuth provider
|
|
24
|
+
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
|
25
|
+
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
26
|
+
picture_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
|
27
|
+
|
|
28
|
+
# Application fields
|
|
29
|
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
30
|
+
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
31
|
+
|
|
32
|
+
# Relationships
|
|
33
|
+
pages: Mapped[list["Page"]] = relationship("Page", back_populates="user")
|
|
34
|
+
roles: Mapped[list["Role"]] = relationship(
|
|
35
|
+
"Role", secondary="user_roles", back_populates="users", lazy="selectin"
|
|
36
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Database service layer for business logic and CRUD operations."""
|