skrift 0.1.0a12__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 (74) hide show
  1. skrift/__init__.py +1 -0
  2. skrift/__main__.py +12 -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 +92 -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/versions/20260129_add_oauth_accounts.py +141 -0
  14. skrift/alembic/versions/20260129_add_provider_metadata.py +29 -0
  15. skrift/alembic.ini +77 -0
  16. skrift/asgi.py +670 -0
  17. skrift/auth/__init__.py +58 -0
  18. skrift/auth/guards.py +130 -0
  19. skrift/auth/roles.py +129 -0
  20. skrift/auth/services.py +184 -0
  21. skrift/cli.py +143 -0
  22. skrift/config.py +259 -0
  23. skrift/controllers/__init__.py +4 -0
  24. skrift/controllers/auth.py +595 -0
  25. skrift/controllers/web.py +67 -0
  26. skrift/db/__init__.py +3 -0
  27. skrift/db/base.py +7 -0
  28. skrift/db/models/__init__.py +7 -0
  29. skrift/db/models/oauth_account.py +50 -0
  30. skrift/db/models/page.py +26 -0
  31. skrift/db/models/role.py +56 -0
  32. skrift/db/models/setting.py +13 -0
  33. skrift/db/models/user.py +36 -0
  34. skrift/db/services/__init__.py +1 -0
  35. skrift/db/services/oauth_service.py +195 -0
  36. skrift/db/services/page_service.py +217 -0
  37. skrift/db/services/setting_service.py +206 -0
  38. skrift/lib/__init__.py +3 -0
  39. skrift/lib/exceptions.py +168 -0
  40. skrift/lib/template.py +108 -0
  41. skrift/setup/__init__.py +14 -0
  42. skrift/setup/config_writer.py +213 -0
  43. skrift/setup/controller.py +888 -0
  44. skrift/setup/middleware.py +89 -0
  45. skrift/setup/providers.py +214 -0
  46. skrift/setup/state.py +315 -0
  47. skrift/static/css/style.css +1003 -0
  48. skrift/templates/admin/admin.html +19 -0
  49. skrift/templates/admin/base.html +24 -0
  50. skrift/templates/admin/pages/edit.html +32 -0
  51. skrift/templates/admin/pages/list.html +62 -0
  52. skrift/templates/admin/settings/site.html +32 -0
  53. skrift/templates/admin/users/list.html +58 -0
  54. skrift/templates/admin/users/roles.html +42 -0
  55. skrift/templates/auth/dummy_login.html +102 -0
  56. skrift/templates/auth/login.html +139 -0
  57. skrift/templates/base.html +52 -0
  58. skrift/templates/error-404.html +19 -0
  59. skrift/templates/error-500.html +19 -0
  60. skrift/templates/error.html +19 -0
  61. skrift/templates/index.html +9 -0
  62. skrift/templates/page.html +26 -0
  63. skrift/templates/setup/admin.html +24 -0
  64. skrift/templates/setup/auth.html +110 -0
  65. skrift/templates/setup/base.html +407 -0
  66. skrift/templates/setup/complete.html +17 -0
  67. skrift/templates/setup/configuring.html +158 -0
  68. skrift/templates/setup/database.html +125 -0
  69. skrift/templates/setup/restart.html +28 -0
  70. skrift/templates/setup/site.html +39 -0
  71. skrift-0.1.0a12.dist-info/METADATA +235 -0
  72. skrift-0.1.0a12.dist-info/RECORD +74 -0
  73. skrift-0.1.0a12.dist-info/WHEEL +4 -0
  74. skrift-0.1.0a12.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,50 @@
1
+ """OAuth account model for storing multiple OAuth identities per user."""
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+ from uuid import UUID
5
+
6
+ from sqlalchemy import ForeignKey, JSON, String, 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
+
15
+ class OAuthAccount(Base):
16
+ """OAuth account model linking OAuth provider identities to users.
17
+
18
+ This allows a single user to have multiple OAuth provider accounts
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
30
+ """
31
+
32
+ __tablename__ = "oauth_accounts"
33
+
34
+ provider: Mapped[str] = mapped_column(String(50), nullable=False)
35
+ provider_account_id: Mapped[str] = mapped_column(String(255), nullable=False)
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
+ )
40
+ user_id: Mapped[UUID] = mapped_column(
41
+ ForeignKey("users.id", ondelete="CASCADE"), nullable=False
42
+ )
43
+
44
+ user: Mapped["User"] = relationship("User", back_populates="oauth_accounts")
45
+
46
+ __table_args__ = (
47
+ UniqueConstraint(
48
+ "provider", "provider_account_id", name="uq_oauth_provider_account"
49
+ ),
50
+ )
@@ -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.oauth_account import OAuthAccount
11
+ from skrift.db.models.page import Page
12
+ from skrift.db.models.role import Role
13
+
14
+
15
+ class User(Base):
16
+ """User model for OAuth authentication."""
17
+
18
+ __tablename__ = "users"
19
+
20
+ # Profile data from OAuth provider
21
+ email: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
22
+ name: Mapped[str | None] = mapped_column(String(255), nullable=True)
23
+ picture_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
24
+
25
+ # Application fields
26
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
27
+ last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
28
+
29
+ # Relationships
30
+ oauth_accounts: Mapped[list["OAuthAccount"]] = relationship(
31
+ "OAuthAccount", back_populates="user", cascade="all, delete-orphan"
32
+ )
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."""
@@ -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
@@ -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