skrift 0.1.0a6__tar.gz → 0.1.0a8__tar.gz

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 (74) hide show
  1. {skrift-0.1.0a6 → skrift-0.1.0a8}/PKG-INFO +1 -1
  2. {skrift-0.1.0a6 → skrift-0.1.0a8}/pyproject.toml +1 -1
  3. skrift-0.1.0a8/skrift/alembic/versions/20260129_add_provider_metadata.py +29 -0
  4. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/asgi.py +10 -8
  5. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/controllers/auth.py +14 -0
  6. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/db/models/oauth_account.py +15 -2
  7. skrift-0.1.0a8/skrift/db/services/oauth_service.py +195 -0
  8. {skrift-0.1.0a6 → skrift-0.1.0a8}/.gitignore +0 -0
  9. {skrift-0.1.0a6 → skrift-0.1.0a8}/README.md +0 -0
  10. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/__init__.py +0 -0
  11. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/__main__.py +0 -0
  12. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/admin/__init__.py +0 -0
  13. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/admin/controller.py +0 -0
  14. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/admin/navigation.py +0 -0
  15. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/alembic/env.py +0 -0
  16. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/alembic/script.py.mako +0 -0
  17. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py +0 -0
  18. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py +0 -0
  19. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py +0 -0
  20. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py +0 -0
  21. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/alembic/versions/20260122_200000_add_settings_table.py +0 -0
  22. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/alembic/versions/20260129_add_oauth_accounts.py +0 -0
  23. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/alembic.ini +0 -0
  24. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/auth/__init__.py +0 -0
  25. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/auth/guards.py +0 -0
  26. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/auth/roles.py +0 -0
  27. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/auth/services.py +0 -0
  28. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/cli.py +0 -0
  29. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/config.py +0 -0
  30. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/controllers/__init__.py +0 -0
  31. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/controllers/web.py +0 -0
  32. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/db/__init__.py +0 -0
  33. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/db/base.py +0 -0
  34. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/db/models/__init__.py +0 -0
  35. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/db/models/page.py +0 -0
  36. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/db/models/role.py +0 -0
  37. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/db/models/setting.py +0 -0
  38. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/db/models/user.py +0 -0
  39. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/db/services/__init__.py +0 -0
  40. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/db/services/page_service.py +0 -0
  41. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/db/services/setting_service.py +0 -0
  42. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/lib/__init__.py +0 -0
  43. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/lib/exceptions.py +0 -0
  44. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/lib/template.py +0 -0
  45. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/setup/__init__.py +0 -0
  46. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/setup/config_writer.py +0 -0
  47. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/setup/controller.py +0 -0
  48. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/setup/middleware.py +0 -0
  49. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/setup/providers.py +0 -0
  50. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/setup/state.py +0 -0
  51. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/static/css/style.css +0 -0
  52. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/admin/admin.html +0 -0
  53. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/admin/base.html +0 -0
  54. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/admin/pages/edit.html +0 -0
  55. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/admin/pages/list.html +0 -0
  56. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/admin/settings/site.html +0 -0
  57. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/admin/users/list.html +0 -0
  58. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/admin/users/roles.html +0 -0
  59. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/auth/dummy_login.html +0 -0
  60. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/auth/login.html +0 -0
  61. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/base.html +0 -0
  62. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/error-404.html +0 -0
  63. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/error-500.html +0 -0
  64. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/error.html +0 -0
  65. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/index.html +0 -0
  66. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/page.html +0 -0
  67. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/setup/admin.html +0 -0
  68. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/setup/auth.html +0 -0
  69. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/setup/base.html +0 -0
  70. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/setup/complete.html +0 -0
  71. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/setup/configuring.html +0 -0
  72. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/setup/database.html +0 -0
  73. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/setup/restart.html +0 -0
  74. {skrift-0.1.0a6 → skrift-0.1.0a8}/skrift/templates/setup/site.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skrift
3
- Version: 0.1.0a6
3
+ Version: 0.1.0a8
4
4
  Summary: A lightweight async Python CMS for crafting modern websites
5
5
  Requires-Python: >=3.13
6
6
  Requires-Dist: advanced-alchemy>=0.26.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "skrift"
3
- version = "0.1.0a6"
3
+ version = "0.1.0a8"
4
4
  description = "A lightweight async Python CMS for crafting modern websites"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -0,0 +1,29 @@
1
+ """add provider_metadata column to oauth_accounts
2
+
3
+ Revision ID: 2b3c4d5e6f7g
4
+ Revises: 1a2b3c4d5e6f
5
+ Create Date: 2026-01-29 12:00:00.000000
6
+
7
+ This migration adds a JSON column to store the full raw OAuth provider response.
8
+ """
9
+ from typing import Sequence, Union
10
+
11
+ from alembic import op
12
+ import sqlalchemy as sa
13
+
14
+
15
+ # revision identifiers, used by Alembic.
16
+ revision: str = '2b3c4d5e6f7g'
17
+ down_revision: Union[str, None] = '1a2b3c4d5e6f'
18
+ branch_labels: Union[str, Sequence[str], None] = None
19
+ depends_on: Union[str, Sequence[str], None] = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ with op.batch_alter_table('oauth_accounts') as batch_op:
24
+ batch_op.add_column(sa.Column('provider_metadata', sa.JSON(), nullable=True))
25
+
26
+
27
+ def downgrade() -> None:
28
+ with op.batch_alter_table('oauth_accounts') as batch_op:
29
+ batch_op.drop_column('provider_metadata')
@@ -11,6 +11,7 @@ import asyncio
11
11
  import hashlib
12
12
  import importlib
13
13
  import os
14
+ import sys
14
15
  from datetime import datetime
15
16
  from pathlib import Path
16
17
 
@@ -57,16 +58,17 @@ def load_controllers() -> list:
57
58
  if not config:
58
59
  return []
59
60
 
61
+ # Add working directory to sys.path for local controller imports
62
+ cwd = os.getcwd()
63
+ if cwd not in sys.path:
64
+ sys.path.insert(0, cwd)
65
+
60
66
  controllers = []
61
67
  for controller_spec in config.get("controllers", []):
62
- try:
63
- module_path, class_name = controller_spec.split(":")
64
- module = importlib.import_module(module_path)
65
- controller_class = getattr(module, class_name)
66
- controllers.append(controller_class)
67
- except Exception:
68
- # Skip controllers that can't be loaded during setup
69
- pass
68
+ module_path, class_name = controller_spec.split(":")
69
+ module = importlib.import_module(module_path)
70
+ controller_class = getattr(module, class_name)
71
+ controllers.append(controller_class)
70
72
 
71
73
  return controllers
72
74
 
@@ -337,6 +337,8 @@ class AuthController(Controller):
337
337
  # Update provider email if changed
338
338
  if email:
339
339
  oauth_account.provider_email = email
340
+ # Update provider metadata
341
+ oauth_account.provider_metadata = user_info
340
342
  else:
341
343
  # Step 2: Check if a user with this email already exists
342
344
  user = None
@@ -352,6 +354,7 @@ class AuthController(Controller):
352
354
  provider=provider,
353
355
  provider_account_id=oauth_id,
354
356
  provider_email=email,
357
+ provider_metadata=user_info,
355
358
  user_id=user.id,
356
359
  )
357
360
  db_session.add(oauth_account)
@@ -375,6 +378,7 @@ class AuthController(Controller):
375
378
  provider=provider,
376
379
  provider_account_id=oauth_id,
377
380
  provider_email=email,
381
+ provider_metadata=user_info,
378
382
  user_id=user.id,
379
383
  )
380
384
  db_session.add(oauth_account)
@@ -444,6 +448,13 @@ class AuthController(Controller):
444
448
  # Generate deterministic oauth_id from email
445
449
  oauth_id = f"dummy_{hashlib.sha256(email.encode()).hexdigest()[:16]}"
446
450
 
451
+ # Create synthetic metadata for dummy provider
452
+ dummy_metadata = {
453
+ "id": oauth_id,
454
+ "email": email,
455
+ "name": name,
456
+ }
457
+
447
458
  # Step 1: Check if OAuth account already exists
448
459
  result = await db_session.execute(
449
460
  select(OAuthAccount)
@@ -462,6 +473,7 @@ class AuthController(Controller):
462
473
  user.email = email
463
474
  user.last_login_at = datetime.now(UTC)
464
475
  oauth_account.provider_email = email
476
+ oauth_account.provider_metadata = dummy_metadata
465
477
  else:
466
478
  # Step 2: Check if a user with this email already exists
467
479
  result = await db_session.execute(
@@ -475,6 +487,7 @@ class AuthController(Controller):
475
487
  provider=DUMMY_PROVIDER_KEY,
476
488
  provider_account_id=oauth_id,
477
489
  provider_email=email,
490
+ provider_metadata=dummy_metadata,
478
491
  user_id=user.id,
479
492
  )
480
493
  db_session.add(oauth_account)
@@ -495,6 +508,7 @@ class AuthController(Controller):
495
508
  provider=DUMMY_PROVIDER_KEY,
496
509
  provider_account_id=oauth_id,
497
510
  provider_email=email,
511
+ provider_metadata=dummy_metadata,
498
512
  user_id=user.id,
499
513
  )
500
514
  db_session.add(oauth_account)
@@ -1,9 +1,9 @@
1
1
  """OAuth account model for storing multiple OAuth identities per user."""
2
2
 
3
- from typing import TYPE_CHECKING
3
+ from typing import TYPE_CHECKING, Any
4
4
  from uuid import UUID
5
5
 
6
- from sqlalchemy import ForeignKey, String, UniqueConstraint
6
+ from sqlalchemy import ForeignKey, JSON, String, UniqueConstraint
7
7
  from sqlalchemy.orm import Mapped, mapped_column, relationship
8
8
 
9
9
  from skrift.db.base import Base
@@ -17,6 +17,16 @@ class OAuthAccount(Base):
17
17
 
18
18
  This allows a single user to have multiple OAuth provider accounts
19
19
  linked to their profile, enabling login via different providers.
20
+
21
+ The provider_metadata column stores the full raw OAuth provider response,
22
+ which varies by provider:
23
+
24
+ - Discord: id, username, global_name, discriminator, avatar, email, verified, locale
25
+ - GitHub: id, login, name, email, avatar_url, bio, company, location, public_repos
26
+ - Google: id, email, name, picture, verified_email, locale, hd
27
+ - Twitter: id, username, name
28
+ - Microsoft: id, displayName, mail, userPrincipalName
29
+ - Facebook: id, name, email, picture.data.url
20
30
  """
21
31
 
22
32
  __tablename__ = "oauth_accounts"
@@ -24,6 +34,9 @@ class OAuthAccount(Base):
24
34
  provider: Mapped[str] = mapped_column(String(50), nullable=False)
25
35
  provider_account_id: Mapped[str] = mapped_column(String(255), nullable=False)
26
36
  provider_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
37
+ provider_metadata: Mapped[dict[str, Any] | None] = mapped_column(
38
+ JSON, nullable=True, default=None
39
+ )
27
40
  user_id: Mapped[UUID] = mapped_column(
28
41
  ForeignKey("users.id", ondelete="CASCADE"), nullable=False
29
42
  )
@@ -0,0 +1,195 @@
1
+ """OAuth service for accessing OAuth account data and provider metadata."""
2
+
3
+ from typing import Any
4
+ from uuid import UUID
5
+
6
+ from sqlalchemy import select
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+
9
+ from skrift.db.models.oauth_account import OAuthAccount
10
+
11
+
12
+ async def get_oauth_account_by_user_and_provider(
13
+ db_session: AsyncSession,
14
+ user_id: UUID,
15
+ provider: str,
16
+ ) -> OAuthAccount | None:
17
+ """Get a specific OAuth account for a user and provider.
18
+
19
+ Args:
20
+ db_session: Database session
21
+ user_id: User UUID
22
+ provider: OAuth provider name (e.g., 'discord', 'github')
23
+
24
+ Returns:
25
+ OAuthAccount or None if not found
26
+ """
27
+ result = await db_session.execute(
28
+ select(OAuthAccount).where(
29
+ OAuthAccount.user_id == user_id,
30
+ OAuthAccount.provider == provider,
31
+ )
32
+ )
33
+ return result.scalar_one_or_none()
34
+
35
+
36
+ async def get_oauth_accounts_by_user(
37
+ db_session: AsyncSession,
38
+ user_id: UUID,
39
+ ) -> list[OAuthAccount]:
40
+ """Get all OAuth accounts linked to a user.
41
+
42
+ Args:
43
+ db_session: Database session
44
+ user_id: User UUID
45
+
46
+ Returns:
47
+ List of OAuthAccount objects
48
+ """
49
+ result = await db_session.execute(
50
+ select(OAuthAccount).where(OAuthAccount.user_id == user_id)
51
+ )
52
+ return list(result.scalars().all())
53
+
54
+
55
+ async def get_provider_metadata(
56
+ db_session: AsyncSession,
57
+ user_id: UUID,
58
+ provider: str,
59
+ ) -> dict[str, Any] | None:
60
+ """Get the raw provider metadata for a user's OAuth account.
61
+
62
+ Args:
63
+ db_session: Database session
64
+ user_id: User UUID
65
+ provider: OAuth provider name
66
+
67
+ Returns:
68
+ Provider metadata dict or None if not found
69
+ """
70
+ oauth_account = await get_oauth_account_by_user_and_provider(
71
+ db_session, user_id, provider
72
+ )
73
+ if oauth_account:
74
+ return oauth_account.provider_metadata
75
+ return None
76
+
77
+
78
+ def extract_metadata_field(
79
+ metadata: dict[str, Any] | None,
80
+ *keys: str,
81
+ default: Any = None,
82
+ ) -> Any:
83
+ """Safely extract a nested field from metadata.
84
+
85
+ Args:
86
+ metadata: Provider metadata dict
87
+ *keys: Sequence of keys for nested access (e.g., 'picture', 'data', 'url')
88
+ default: Default value if field not found
89
+
90
+ Returns:
91
+ Field value or default
92
+ """
93
+ if metadata is None:
94
+ return default
95
+
96
+ current = metadata
97
+ for key in keys:
98
+ if isinstance(current, dict) and key in current:
99
+ current = current[key]
100
+ else:
101
+ return default
102
+ return current
103
+
104
+
105
+ async def get_provider_username(
106
+ db_session: AsyncSession,
107
+ user_id: UUID,
108
+ provider: str,
109
+ ) -> str | None:
110
+ """Get the username from a provider's metadata.
111
+
112
+ Provider-specific username fields:
113
+ - Discord: username
114
+ - GitHub: login
115
+ - Twitter: username
116
+ - Google: email (no username concept)
117
+ - Microsoft: userPrincipalName
118
+ - Facebook: name (no username concept)
119
+
120
+ Args:
121
+ db_session: Database session
122
+ user_id: User UUID
123
+ provider: OAuth provider name
124
+
125
+ Returns:
126
+ Username string or None
127
+ """
128
+ metadata = await get_provider_metadata(db_session, user_id, provider)
129
+ if metadata is None:
130
+ return None
131
+
132
+ # Provider-specific username extraction
133
+ username_fields = {
134
+ "discord": "username",
135
+ "github": "login",
136
+ "twitter": "username",
137
+ "microsoft": "userPrincipalName",
138
+ }
139
+
140
+ field = username_fields.get(provider)
141
+ if field:
142
+ return extract_metadata_field(metadata, field)
143
+
144
+ # Fallback for providers without usernames
145
+ return extract_metadata_field(metadata, "email") or extract_metadata_field(
146
+ metadata, "name"
147
+ )
148
+
149
+
150
+ async def get_provider_avatar_url(
151
+ db_session: AsyncSession,
152
+ user_id: UUID,
153
+ provider: str,
154
+ ) -> str | None:
155
+ """Get the avatar URL from a provider's metadata.
156
+
157
+ Provider-specific avatar URL construction:
158
+ - Discord: Constructed from id + avatar hash
159
+ - GitHub: avatar_url
160
+ - Google: picture
161
+ - Microsoft: No direct URL (requires Graph API call)
162
+ - Facebook: picture.data.url
163
+ - Twitter: No avatar in basic userinfo
164
+
165
+ Args:
166
+ db_session: Database session
167
+ user_id: User UUID
168
+ provider: OAuth provider name
169
+
170
+ Returns:
171
+ Avatar URL string or None
172
+ """
173
+ metadata = await get_provider_metadata(db_session, user_id, provider)
174
+ if metadata is None:
175
+ return None
176
+
177
+ if provider == "discord":
178
+ # Discord avatar URL must be constructed
179
+ user_id_discord = extract_metadata_field(metadata, "id")
180
+ avatar_hash = extract_metadata_field(metadata, "avatar")
181
+ if user_id_discord and avatar_hash:
182
+ return f"https://cdn.discordapp.com/avatars/{user_id_discord}/{avatar_hash}.png"
183
+ return None
184
+
185
+ if provider == "github":
186
+ return extract_metadata_field(metadata, "avatar_url")
187
+
188
+ if provider == "google":
189
+ return extract_metadata_field(metadata, "picture")
190
+
191
+ if provider == "facebook":
192
+ return extract_metadata_field(metadata, "picture", "data", "url")
193
+
194
+ # Microsoft and Twitter don't provide direct avatar URLs
195
+ return None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes