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.
Files changed (68) hide show
  1. skrift/__init__.py +1 -0
  2. skrift/__main__.py +17 -0
  3. skrift/admin/__init__.py +11 -0
  4. skrift/admin/controller.py +452 -0
  5. skrift/admin/navigation.py +105 -0
  6. skrift/alembic/env.py +91 -0
  7. skrift/alembic/script.py.mako +26 -0
  8. skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py +70 -0
  9. skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py +57 -0
  10. skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py +31 -0
  11. skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py +43 -0
  12. skrift/alembic/versions/20260122_200000_add_settings_table.py +38 -0
  13. skrift/alembic.ini +77 -0
  14. skrift/asgi.py +545 -0
  15. skrift/auth/__init__.py +58 -0
  16. skrift/auth/guards.py +130 -0
  17. skrift/auth/roles.py +94 -0
  18. skrift/auth/services.py +184 -0
  19. skrift/cli.py +45 -0
  20. skrift/config.py +192 -0
  21. skrift/controllers/__init__.py +4 -0
  22. skrift/controllers/auth.py +371 -0
  23. skrift/controllers/web.py +67 -0
  24. skrift/db/__init__.py +3 -0
  25. skrift/db/base.py +7 -0
  26. skrift/db/models/__init__.py +6 -0
  27. skrift/db/models/page.py +26 -0
  28. skrift/db/models/role.py +56 -0
  29. skrift/db/models/setting.py +13 -0
  30. skrift/db/models/user.py +36 -0
  31. skrift/db/services/__init__.py +1 -0
  32. skrift/db/services/page_service.py +217 -0
  33. skrift/db/services/setting_service.py +206 -0
  34. skrift/lib/__init__.py +3 -0
  35. skrift/lib/exceptions.py +168 -0
  36. skrift/lib/template.py +108 -0
  37. skrift/setup/__init__.py +14 -0
  38. skrift/setup/config_writer.py +211 -0
  39. skrift/setup/controller.py +751 -0
  40. skrift/setup/middleware.py +89 -0
  41. skrift/setup/providers.py +163 -0
  42. skrift/setup/state.py +134 -0
  43. skrift/static/css/style.css +998 -0
  44. skrift/templates/admin/admin.html +19 -0
  45. skrift/templates/admin/base.html +24 -0
  46. skrift/templates/admin/pages/edit.html +32 -0
  47. skrift/templates/admin/pages/list.html +62 -0
  48. skrift/templates/admin/settings/site.html +32 -0
  49. skrift/templates/admin/users/list.html +58 -0
  50. skrift/templates/admin/users/roles.html +42 -0
  51. skrift/templates/auth/login.html +125 -0
  52. skrift/templates/base.html +52 -0
  53. skrift/templates/error-404.html +19 -0
  54. skrift/templates/error-500.html +19 -0
  55. skrift/templates/error.html +19 -0
  56. skrift/templates/index.html +9 -0
  57. skrift/templates/page.html +26 -0
  58. skrift/templates/setup/admin.html +24 -0
  59. skrift/templates/setup/auth.html +110 -0
  60. skrift/templates/setup/base.html +407 -0
  61. skrift/templates/setup/complete.html +17 -0
  62. skrift/templates/setup/database.html +125 -0
  63. skrift/templates/setup/restart.html +28 -0
  64. skrift/templates/setup/site.html +39 -0
  65. skrift-0.1.0a1.dist-info/METADATA +233 -0
  66. skrift-0.1.0a1.dist-info/RECORD +68 -0
  67. skrift-0.1.0a1.dist-info/WHEEL +4 -0
  68. 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
@@ -0,0 +1,3 @@
1
+ from skrift.db.base import Base
2
+
3
+ __all__ = ["Base"]
skrift/db/base.py ADDED
@@ -0,0 +1,7 @@
1
+ from advanced_alchemy.base import UUIDAuditBase
2
+
3
+
4
+ class Base(UUIDAuditBase):
5
+ """Base model class with UUID primary key and audit timestamps."""
6
+
7
+ __abstract__ = True
@@ -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"]
@@ -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)
@@ -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)
@@ -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."""