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.
- {skrift-0.1.0a9 → skrift-0.1.0a11}/PKG-INFO +1 -1
- {skrift-0.1.0a9 → skrift-0.1.0a11}/pyproject.toml +5 -1
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/asgi.py +108 -1
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/config.py +1 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/controllers/auth.py +68 -4
- {skrift-0.1.0a9 → skrift-0.1.0a11}/.gitignore +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/README.md +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/__init__.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/__main__.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/admin/__init__.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/admin/controller.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/admin/navigation.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic/env.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic/script.py.mako +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic/versions/20260122_200000_add_settings_table.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic/versions/20260129_add_oauth_accounts.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic/versions/20260129_add_provider_metadata.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic.ini +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/auth/__init__.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/auth/guards.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/auth/roles.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/auth/services.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/cli.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/controllers/__init__.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/controllers/web.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/__init__.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/base.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/models/__init__.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/models/oauth_account.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/models/page.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/models/role.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/models/setting.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/models/user.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/services/__init__.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/services/oauth_service.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/services/page_service.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/db/services/setting_service.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/lib/__init__.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/lib/exceptions.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/lib/template.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/setup/__init__.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/setup/config_writer.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/setup/controller.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/setup/middleware.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/setup/providers.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/setup/state.py +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/static/css/style.css +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/admin/admin.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/admin/base.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/admin/pages/edit.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/admin/pages/list.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/admin/settings/site.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/admin/users/list.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/admin/users/roles.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/auth/dummy_login.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/auth/login.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/base.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/error-404.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/error-500.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/error.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/index.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/page.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/setup/admin.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/setup/auth.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/setup/base.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/setup/complete.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/setup/configuring.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/setup/database.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/setup/restart.html +0 -0
- {skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/templates/setup/site.html +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "skrift"
|
|
3
|
-
version = "0.1.
|
|
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={
|
|
@@ -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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic/versions/20260122_200000_add_settings_table.py
RENAMED
|
File without changes
|
|
File without changes
|
{skrift-0.1.0a9 → skrift-0.1.0a11}/skrift/alembic/versions/20260129_add_provider_metadata.py
RENAMED
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|