skrift 0.1.0a9__tar.gz → 0.1.0a11__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.0a9 → skrift-0.1.0a11}/PKG-INFO +1 -1
  2. {skrift-0.1.0a9 → skrift-0.1.0a11}/pyproject.toml +5 -1
  3. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/asgi.py +108 -1
  4. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/config.py +1 -0
  5. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/controllers/auth.py +68 -4
  6. {skrift-0.1.0a9 → skrift-0.1.0a11}/.gitignore +0 -0
  7. {skrift-0.1.0a9 → skrift-0.1.0a11}/README.md +0 -0
  8. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/__init__.py +0 -0
  9. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/__main__.py +0 -0
  10. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/admin/__init__.py +0 -0
  11. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/admin/controller.py +0 -0
  12. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/admin/navigation.py +0 -0
  13. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic/env.py +0 -0
  14. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic/script.py.mako +0 -0
  15. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py +0 -0
  16. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py +0 -0
  17. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py +0 -0
  18. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py +0 -0
  19. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic/versions/20260122_200000_add_settings_table.py +0 -0
  20. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic/versions/20260129_add_oauth_accounts.py +0 -0
  21. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic/versions/20260129_add_provider_metadata.py +0 -0
  22. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic.ini +0 -0
  23. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/auth/__init__.py +0 -0
  24. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/auth/guards.py +0 -0
  25. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/auth/roles.py +0 -0
  26. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/auth/services.py +0 -0
  27. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/cli.py +0 -0
  28. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/controllers/__init__.py +0 -0
  29. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/controllers/web.py +0 -0
  30. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/__init__.py +0 -0
  31. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/base.py +0 -0
  32. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/models/__init__.py +0 -0
  33. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/models/oauth_account.py +0 -0
  34. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/models/page.py +0 -0
  35. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/models/role.py +0 -0
  36. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/models/setting.py +0 -0
  37. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/models/user.py +0 -0
  38. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/services/__init__.py +0 -0
  39. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/services/oauth_service.py +0 -0
  40. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/services/page_service.py +0 -0
  41. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/services/setting_service.py +0 -0
  42. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/lib/__init__.py +0 -0
  43. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/lib/exceptions.py +0 -0
  44. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/lib/template.py +0 -0
  45. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/setup/__init__.py +0 -0
  46. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/setup/config_writer.py +0 -0
  47. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/setup/controller.py +0 -0
  48. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/setup/middleware.py +0 -0
  49. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/setup/providers.py +0 -0
  50. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/setup/state.py +0 -0
  51. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/static/css/style.css +0 -0
  52. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/admin/admin.html +0 -0
  53. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/admin/base.html +0 -0
  54. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/admin/pages/edit.html +0 -0
  55. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/admin/pages/list.html +0 -0
  56. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/admin/settings/site.html +0 -0
  57. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/admin/users/list.html +0 -0
  58. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/admin/users/roles.html +0 -0
  59. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/auth/dummy_login.html +0 -0
  60. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/auth/login.html +0 -0
  61. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/base.html +0 -0
  62. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/error-404.html +0 -0
  63. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/error-500.html +0 -0
  64. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/error.html +0 -0
  65. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/index.html +0 -0
  66. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/page.html +0 -0
  67. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/setup/admin.html +0 -0
  68. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/setup/auth.html +0 -0
  69. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/setup/base.html +0 -0
  70. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/setup/complete.html +0 -0
  71. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/setup/configuring.html +0 -0
  72. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/setup/database.html +0 -0
  73. {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/setup/restart.html +0 -0
  74. {skrift-0.1.0a9 → skrift-0.1.0a11}/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.0a9
3
+ Version: 0.1.0a11
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.0a9"
3
+ version = "0.1.0a11"
4
4
  description = "A lightweight async Python CMS for crafting modern websites"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -30,6 +30,10 @@ requires = ["hatchling"]
30
30
  build-backend = "hatchling.build"
31
31
 
32
32
  [dependency-groups]
33
+ dev = [
34
+ "pytest>=8.0.0",
35
+ "pytest-asyncio>=0.24.0",
36
+ ]
33
37
  docs = [
34
38
  "zensical>=0.0.19",
35
39
  ]
@@ -26,6 +26,7 @@ from litestar import Litestar
26
26
  from litestar.config.compression import CompressionConfig
27
27
  from litestar.contrib.jinja import JinjaTemplateEngine
28
28
  from litestar.exceptions import HTTPException
29
+ from litestar.middleware import DefineMiddleware
29
30
  from litestar.middleware.session.client_side import CookieBackendConfig
30
31
  from litestar.static_files import create_static_files_router
31
32
  from litestar.template import TemplateConfig
@@ -73,6 +74,109 @@ def load_controllers() -> list:
73
74
  return controllers
74
75
 
75
76
 
77
+ def _load_middleware_factory(spec: str):
78
+ """Import a single middleware factory from a module:name spec.
79
+
80
+ Args:
81
+ spec: String in format "module.path:factory_name"
82
+
83
+ Returns:
84
+ The callable middleware factory
85
+
86
+ Raises:
87
+ ValueError: If spec doesn't contain exactly one colon
88
+ ImportError: If the module cannot be imported
89
+ AttributeError: If the factory doesn't exist in the module
90
+ TypeError: If the factory is not callable
91
+ """
92
+ if ":" not in spec:
93
+ raise ValueError(
94
+ f"Invalid middleware spec '{spec}': must be in format 'module:factory'"
95
+ )
96
+
97
+ parts = spec.split(":")
98
+ if len(parts) != 2:
99
+ raise ValueError(
100
+ f"Invalid middleware spec '{spec}': must contain exactly one colon"
101
+ )
102
+
103
+ module_path, factory_name = parts
104
+ module = importlib.import_module(module_path)
105
+ factory = getattr(module, factory_name)
106
+
107
+ if not callable(factory):
108
+ raise TypeError(
109
+ f"Middleware factory '{spec}' is not callable"
110
+ )
111
+
112
+ return factory
113
+
114
+
115
+ def load_middleware() -> list:
116
+ """Load middleware from app.yaml configuration.
117
+
118
+ Supports two formats in app.yaml:
119
+
120
+ Simple (no args):
121
+ middleware:
122
+ - myapp.middleware:create_logging_middleware
123
+
124
+ With kwargs:
125
+ middleware:
126
+ - factory: myapp.middleware:create_rate_limit_middleware
127
+ kwargs:
128
+ requests_per_minute: 100
129
+
130
+ Returns:
131
+ List of middleware factories or DefineMiddleware instances
132
+ """
133
+ config_path = get_config_path()
134
+
135
+ if not config_path.exists():
136
+ return []
137
+
138
+ with open(config_path, "r") as f:
139
+ config = yaml.safe_load(f)
140
+
141
+ if not config:
142
+ return []
143
+
144
+ middleware_specs = config.get("middleware", [])
145
+ if not middleware_specs:
146
+ return []
147
+
148
+ # Add working directory to sys.path for local middleware imports
149
+ cwd = os.getcwd()
150
+ if cwd not in sys.path:
151
+ sys.path.insert(0, cwd)
152
+
153
+ middleware = []
154
+ for spec in middleware_specs:
155
+ if isinstance(spec, str):
156
+ # Simple format: "module:factory"
157
+ factory = _load_middleware_factory(spec)
158
+ middleware.append(factory)
159
+ elif isinstance(spec, dict):
160
+ # Dict format with optional kwargs
161
+ if "factory" not in spec:
162
+ raise ValueError(
163
+ f"Middleware dict spec must have 'factory' key: {spec}"
164
+ )
165
+ factory = _load_middleware_factory(spec["factory"])
166
+ kwargs = spec.get("kwargs", {})
167
+ if kwargs:
168
+ middleware.append(DefineMiddleware(factory, **kwargs))
169
+ else:
170
+ middleware.append(factory)
171
+ else:
172
+ raise ValueError(
173
+ f"Invalid middleware spec type: {type(spec).__name__}. "
174
+ "Must be string or dict."
175
+ )
176
+
177
+ return middleware
178
+
179
+
76
180
  async def check_setup_complete(db_config: SQLAlchemyAsyncConfig) -> bool:
77
181
  """Check if setup has been completed."""
78
182
  try:
@@ -282,6 +386,9 @@ def create_app() -> Litestar:
282
386
  # Load controllers from app.yaml
283
387
  controllers = load_controllers()
284
388
 
389
+ # Load middleware from app.yaml
390
+ user_middleware = load_middleware()
391
+
285
392
  # Database configuration
286
393
  if "sqlite" in settings.db.url:
287
394
  engine_config = EngineConfig(echo=settings.db.echo)
@@ -350,7 +457,7 @@ def create_app() -> Litestar:
350
457
  on_startup=[on_startup],
351
458
  route_handlers=[*controllers, static_files_router],
352
459
  plugins=[SQLAlchemyPlugin(config=db_config)],
353
- middleware=[session_config.middleware],
460
+ middleware=[session_config.middleware, *user_middleware],
354
461
  template_config=template_config,
355
462
  compression_config=CompressionConfig(backend="gzip"),
356
463
  exception_handlers={
@@ -137,6 +137,7 @@ class AuthConfig(BaseModel):
137
137
  """Authentication configuration."""
138
138
 
139
139
  redirect_base_url: str = "http://localhost:8000"
140
+ allowed_redirect_domains: list[str] = []
140
141
  providers: dict[str, ProviderConfig] = {}
141
142
 
142
143
  @classmethod
@@ -5,11 +5,12 @@ Also supports a development-only "dummy" provider for testing.
5
5
  """
6
6
 
7
7
  import base64
8
+ import fnmatch
8
9
  import hashlib
9
10
  import secrets
10
11
  from datetime import UTC, datetime
11
12
  from typing import Annotated
12
- from urllib.parse import urlencode
13
+ from urllib.parse import urlencode, urlparse
13
14
 
14
15
  import httpx
15
16
  from litestar import Controller, Request, get, post
@@ -26,6 +27,56 @@ from skrift.db.models.user import User
26
27
  from skrift.setup.providers import DUMMY_PROVIDER_KEY, OAUTH_PROVIDERS, get_provider_info
27
28
 
28
29
 
30
+ def _is_safe_redirect_url(url: str, allowed_domains: list[str]) -> bool:
31
+ """Check if URL is safe to redirect to.
32
+
33
+ Supports wildcard patterns using fnmatch-style matching:
34
+ - "*.example.com" matches any subdomain of example.com
35
+ - "app-*.example.com" matches app-foo.example.com, app-bar.example.com, etc.
36
+ - "example.com" (no wildcards) matches example.com and all subdomains
37
+ """
38
+ # Relative paths are always safe (but not protocol-relative //domain.com)
39
+ if url.startswith("/") and not url.startswith("//"):
40
+ return True
41
+
42
+ # Parse absolute URL
43
+ try:
44
+ parsed = urlparse(url)
45
+ except Exception:
46
+ return False
47
+
48
+ # Must have scheme and netloc
49
+ if not parsed.scheme or not parsed.netloc:
50
+ return False
51
+
52
+ # Only allow http/https
53
+ if parsed.scheme not in ("http", "https"):
54
+ return False
55
+
56
+ # Check if domain matches allowed list
57
+ host = parsed.netloc.lower().split(":")[0] # Remove port
58
+ for pattern in allowed_domains:
59
+ pattern = pattern.lower()
60
+ # If pattern contains wildcards, use fnmatch
61
+ if "*" in pattern or "?" in pattern:
62
+ if fnmatch.fnmatch(host, pattern):
63
+ return True
64
+ else:
65
+ # No wildcards: exact match or subdomain match
66
+ if host == pattern or host.endswith(f".{pattern}"):
67
+ return True
68
+
69
+ return False
70
+
71
+
72
+ def _get_safe_redirect_url(request: Request, allowed_domains: list[str], default: str = "/") -> str:
73
+ """Get the next redirect URL from session, validating it's safe."""
74
+ next_url = request.session.pop("auth_next", None)
75
+ if next_url and _is_safe_redirect_url(next_url, allowed_domains):
76
+ return next_url
77
+ return default
78
+
79
+
29
80
  def get_auth_url(provider: str, settings, state: str, code_challenge: str | None = None) -> str:
30
81
  """Build the OAuth authorization URL for a provider."""
31
82
  provider_info = get_provider_info(provider)
@@ -230,11 +281,16 @@ class AuthController(Controller):
230
281
  self,
231
282
  request: Request,
232
283
  provider: str,
284
+ next_url: Annotated[str | None, Parameter(query="next")] = None,
233
285
  ) -> Redirect | TemplateResponse:
234
286
  """Redirect to OAuth provider consent screen, or show dummy login form."""
235
287
  settings = get_settings()
236
288
  provider_info = get_provider_info(provider)
237
289
 
290
+ # Store next URL in session if provided and valid
291
+ if next_url and _is_safe_redirect_url(next_url, settings.auth.allowed_redirect_domains):
292
+ request.session["auth_next"] = next_url
293
+
238
294
  if not provider_info:
239
295
  raise NotFoundException(f"Unknown provider: {provider}")
240
296
 
@@ -392,14 +448,22 @@ class AuthController(Controller):
392
448
  request.session["user_picture_url"] = user.picture_url
393
449
  request.session["flash"] = "Successfully logged in!"
394
450
 
395
- return Redirect(path="/")
451
+ return Redirect(path=_get_safe_redirect_url(request, settings.auth.allowed_redirect_domains))
396
452
 
397
453
  @get("/login")
398
- async def login_page(self, request: Request) -> TemplateResponse:
454
+ async def login_page(
455
+ self,
456
+ request: Request,
457
+ next_url: Annotated[str | None, Parameter(query="next")] = None,
458
+ ) -> TemplateResponse:
399
459
  """Show login page with available providers."""
400
460
  flash = request.session.pop("flash", None)
401
461
  settings = get_settings()
402
462
 
463
+ # Store next URL in session if provided and valid
464
+ if next_url and _is_safe_redirect_url(next_url, settings.auth.allowed_redirect_domains):
465
+ request.session["auth_next"] = next_url
466
+
403
467
  # Get configured providers (excluding dummy from main list)
404
468
  configured_providers = list(settings.auth.providers.keys())
405
469
  providers = {
@@ -522,7 +586,7 @@ class AuthController(Controller):
522
586
  request.session["user_picture_url"] = user.picture_url
523
587
  request.session["flash"] = "Successfully logged in!"
524
588
 
525
- return Redirect(path="/")
589
+ return Redirect(path=_get_safe_redirect_url(request, settings.auth.allowed_redirect_domains))
526
590
 
527
591
  @get("/logout")
528
592
  async def logout(self, request: Request) -> Redirect:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes