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,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
@@ -0,0 +1,3 @@
1
+ from skrift.lib.template import Template
2
+
3
+ __all__ = ["Template"]
@@ -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
+ )
@@ -0,0 +1,14 @@
1
+ """Setup wizard package for first-time Skrift configuration."""
2
+
3
+ from skrift.setup.state import is_setup_complete, get_setup_step
4
+ from skrift.setup.middleware import SetupMiddleware, create_dynamic_setup_middleware_factory
5
+ from skrift.setup.controller import SetupController, SetupAuthController
6
+
7
+ __all__ = [
8
+ "is_setup_complete",
9
+ "get_setup_step",
10
+ "SetupMiddleware",
11
+ "SetupController",
12
+ "SetupAuthController",
13
+ "create_dynamic_setup_middleware_factory",
14
+ ]