skrift 0.1.0a1__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.
- skrift/__init__.py +1 -0
- skrift/__main__.py +17 -0
- skrift/admin/__init__.py +11 -0
- skrift/admin/controller.py +452 -0
- skrift/admin/navigation.py +105 -0
- skrift/alembic/env.py +91 -0
- skrift/alembic/script.py.mako +26 -0
- skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py +70 -0
- skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py +57 -0
- skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py +31 -0
- skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py +43 -0
- skrift/alembic/versions/20260122_200000_add_settings_table.py +38 -0
- skrift/alembic.ini +77 -0
- skrift/asgi.py +545 -0
- skrift/auth/__init__.py +58 -0
- skrift/auth/guards.py +130 -0
- skrift/auth/roles.py +94 -0
- skrift/auth/services.py +184 -0
- skrift/cli.py +45 -0
- skrift/config.py +192 -0
- skrift/controllers/__init__.py +4 -0
- skrift/controllers/auth.py +371 -0
- skrift/controllers/web.py +67 -0
- skrift/db/__init__.py +3 -0
- skrift/db/base.py +7 -0
- skrift/db/models/__init__.py +6 -0
- skrift/db/models/page.py +26 -0
- skrift/db/models/role.py +56 -0
- skrift/db/models/setting.py +13 -0
- skrift/db/models/user.py +36 -0
- skrift/db/services/__init__.py +1 -0
- skrift/db/services/page_service.py +217 -0
- skrift/db/services/setting_service.py +206 -0
- skrift/lib/__init__.py +3 -0
- skrift/lib/exceptions.py +168 -0
- skrift/lib/template.py +108 -0
- skrift/setup/__init__.py +14 -0
- skrift/setup/config_writer.py +211 -0
- skrift/setup/controller.py +751 -0
- skrift/setup/middleware.py +89 -0
- skrift/setup/providers.py +163 -0
- skrift/setup/state.py +134 -0
- skrift/static/css/style.css +998 -0
- skrift/templates/admin/admin.html +19 -0
- skrift/templates/admin/base.html +24 -0
- skrift/templates/admin/pages/edit.html +32 -0
- skrift/templates/admin/pages/list.html +62 -0
- skrift/templates/admin/settings/site.html +32 -0
- skrift/templates/admin/users/list.html +58 -0
- skrift/templates/admin/users/roles.html +42 -0
- skrift/templates/auth/login.html +125 -0
- skrift/templates/base.html +52 -0
- skrift/templates/error-404.html +19 -0
- skrift/templates/error-500.html +19 -0
- skrift/templates/error.html +19 -0
- skrift/templates/index.html +9 -0
- skrift/templates/page.html +26 -0
- skrift/templates/setup/admin.html +24 -0
- skrift/templates/setup/auth.html +110 -0
- skrift/templates/setup/base.html +407 -0
- skrift/templates/setup/complete.html +17 -0
- skrift/templates/setup/database.html +125 -0
- skrift/templates/setup/restart.html +28 -0
- skrift/templates/setup/site.html +39 -0
- skrift-0.1.0a1.dist-info/METADATA +233 -0
- skrift-0.1.0a1.dist-info/RECORD +68 -0
- skrift-0.1.0a1.dist-info/WHEEL +4 -0
- skrift-0.1.0a1.dist-info/entry_points.txt +3 -0
skrift/asgi.py
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
"""ASGI application factory for Skrift.
|
|
2
|
+
|
|
3
|
+
This module handles application creation with setup wizard support.
|
|
4
|
+
The application uses a dispatcher architecture:
|
|
5
|
+
- AppDispatcher routes requests to either the setup app or the main app
|
|
6
|
+
- When setup completes, the dispatcher switches all traffic to the main app
|
|
7
|
+
- No server restart required after setup
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import hashlib
|
|
12
|
+
import importlib
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
from advanced_alchemy.config import EngineConfig
|
|
18
|
+
from advanced_alchemy.extensions.litestar import (
|
|
19
|
+
AsyncSessionConfig,
|
|
20
|
+
SQLAlchemyAsyncConfig,
|
|
21
|
+
SQLAlchemyPlugin,
|
|
22
|
+
)
|
|
23
|
+
from litestar import Litestar
|
|
24
|
+
from litestar.config.compression import CompressionConfig
|
|
25
|
+
from litestar.contrib.jinja import JinjaTemplateEngine
|
|
26
|
+
from litestar.exceptions import HTTPException
|
|
27
|
+
from litestar.middleware.session.client_side import CookieBackendConfig
|
|
28
|
+
from litestar.static_files import create_static_files_router
|
|
29
|
+
from litestar.template import TemplateConfig
|
|
30
|
+
from litestar.types import ASGIApp, Receive, Scope, Send
|
|
31
|
+
|
|
32
|
+
from skrift.config import get_settings, is_config_valid
|
|
33
|
+
from skrift.db.base import Base
|
|
34
|
+
from skrift.db.services.setting_service import (
|
|
35
|
+
load_site_settings_cache,
|
|
36
|
+
get_cached_site_name,
|
|
37
|
+
get_cached_site_tagline,
|
|
38
|
+
get_cached_site_copyright_holder,
|
|
39
|
+
get_cached_site_copyright_start_year,
|
|
40
|
+
get_setting,
|
|
41
|
+
SETUP_COMPLETED_AT_KEY,
|
|
42
|
+
)
|
|
43
|
+
from skrift.lib.exceptions import http_exception_handler, internal_server_error_handler
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_controllers() -> list:
|
|
47
|
+
"""Load controllers from app.yaml configuration."""
|
|
48
|
+
config_path = Path.cwd() / "app.yaml"
|
|
49
|
+
|
|
50
|
+
if not config_path.exists():
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
with open(config_path, "r") as f:
|
|
54
|
+
config = yaml.safe_load(f)
|
|
55
|
+
|
|
56
|
+
if not config:
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
controllers = []
|
|
60
|
+
for controller_spec in config.get("controllers", []):
|
|
61
|
+
try:
|
|
62
|
+
module_path, class_name = controller_spec.split(":")
|
|
63
|
+
module = importlib.import_module(module_path)
|
|
64
|
+
controller_class = getattr(module, class_name)
|
|
65
|
+
controllers.append(controller_class)
|
|
66
|
+
except Exception:
|
|
67
|
+
# Skip controllers that can't be loaded during setup
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
return controllers
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def check_setup_complete(db_config: SQLAlchemyAsyncConfig) -> bool:
|
|
74
|
+
"""Check if setup has been completed."""
|
|
75
|
+
try:
|
|
76
|
+
async with db_config.get_session() as session:
|
|
77
|
+
value = await get_setting(session, SETUP_COMPLETED_AT_KEY)
|
|
78
|
+
return value is not None
|
|
79
|
+
except Exception:
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Module-level reference to the dispatcher for state updates
|
|
84
|
+
_dispatcher: "AppDispatcher | None" = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def lock_setup_in_dispatcher() -> None:
|
|
88
|
+
"""Lock setup in the dispatcher, making /setup/* return 404.
|
|
89
|
+
|
|
90
|
+
This is called when setup is complete and user visits the main site.
|
|
91
|
+
"""
|
|
92
|
+
global _dispatcher
|
|
93
|
+
if _dispatcher is not None:
|
|
94
|
+
_dispatcher.setup_locked = True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AppDispatcher:
|
|
98
|
+
"""ASGI dispatcher that routes between setup and main apps.
|
|
99
|
+
|
|
100
|
+
Uses a simple setup_locked flag:
|
|
101
|
+
- When True: /setup/* returns 404 (via main app), all traffic goes to main app
|
|
102
|
+
- When False: Setup routes work, check DB to determine routing for other paths
|
|
103
|
+
|
|
104
|
+
The main_app can be None at startup if config isn't valid yet. It will be
|
|
105
|
+
lazily created after setup completes.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
setup_app: ASGIApp,
|
|
111
|
+
db_url: str | None = None,
|
|
112
|
+
main_app: Litestar | None = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
self._main_app = main_app
|
|
115
|
+
self._main_app_error: str | None = None
|
|
116
|
+
self._main_app_started = main_app is not None # Track if lifespan started
|
|
117
|
+
self.setup_app = setup_app
|
|
118
|
+
self.setup_locked = False # When True, setup is inaccessible
|
|
119
|
+
self._db_url = db_url
|
|
120
|
+
self._lifespan_task: asyncio.Task | None = None
|
|
121
|
+
self._shutdown_event: asyncio.Event | None = None
|
|
122
|
+
|
|
123
|
+
async def _get_or_create_main_app(self) -> Litestar | None:
|
|
124
|
+
"""Get the main app, creating it lazily if needed."""
|
|
125
|
+
if self._main_app is None and self._main_app_error is None:
|
|
126
|
+
try:
|
|
127
|
+
self._main_app = create_app()
|
|
128
|
+
# Run lifespan startup for the newly created app
|
|
129
|
+
await self._start_main_app_lifespan()
|
|
130
|
+
except Exception as e:
|
|
131
|
+
self._main_app_error = str(e)
|
|
132
|
+
self._main_app = None
|
|
133
|
+
return self._main_app
|
|
134
|
+
|
|
135
|
+
async def _start_main_app_lifespan(self) -> None:
|
|
136
|
+
"""Start the main app's lifespan (runs startup handlers)."""
|
|
137
|
+
if self._main_app is None or self._main_app_started:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
startup_complete = asyncio.Event()
|
|
141
|
+
startup_failed: str | None = None
|
|
142
|
+
self._shutdown_event = asyncio.Event()
|
|
143
|
+
message_queue: asyncio.Queue = asyncio.Queue()
|
|
144
|
+
|
|
145
|
+
# Queue the startup message
|
|
146
|
+
await message_queue.put({"type": "lifespan.startup"})
|
|
147
|
+
|
|
148
|
+
async def receive():
|
|
149
|
+
# First return startup, then wait for shutdown signal
|
|
150
|
+
msg = await message_queue.get()
|
|
151
|
+
return msg
|
|
152
|
+
|
|
153
|
+
async def send(message):
|
|
154
|
+
nonlocal startup_failed
|
|
155
|
+
if message["type"] == "lifespan.startup.complete":
|
|
156
|
+
startup_complete.set()
|
|
157
|
+
elif message["type"] == "lifespan.startup.failed":
|
|
158
|
+
startup_failed = message.get("message", "Startup failed")
|
|
159
|
+
startup_complete.set()
|
|
160
|
+
|
|
161
|
+
scope = {"type": "lifespan", "asgi": {"version": "3.0"}, "state": {}}
|
|
162
|
+
|
|
163
|
+
async def run_lifespan():
|
|
164
|
+
try:
|
|
165
|
+
await self._main_app(scope, receive, send)
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
# Start lifespan handler in background
|
|
170
|
+
self._lifespan_task = asyncio.create_task(run_lifespan())
|
|
171
|
+
|
|
172
|
+
# Wait for startup to complete
|
|
173
|
+
await startup_complete.wait()
|
|
174
|
+
|
|
175
|
+
if startup_failed:
|
|
176
|
+
raise RuntimeError(startup_failed)
|
|
177
|
+
|
|
178
|
+
self._main_app_started = True
|
|
179
|
+
|
|
180
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
181
|
+
if scope["type"] != "http":
|
|
182
|
+
# Lifespan events go to setup app if no main app yet
|
|
183
|
+
app = self._main_app or self.setup_app
|
|
184
|
+
await app(scope, receive, send)
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
path = scope.get("path", "")
|
|
188
|
+
|
|
189
|
+
# If setup is locked and we have main app, it handles EVERYTHING
|
|
190
|
+
if self.setup_locked and self._main_app:
|
|
191
|
+
await self._main_app(scope, receive, send)
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
# Setup not locked - /setup/* always goes to setup app
|
|
195
|
+
if path.startswith("/setup") or path.startswith("/static"):
|
|
196
|
+
await self.setup_app(scope, receive, send)
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
# For /auth/* during setup, route to setup app (OAuth callbacks)
|
|
200
|
+
if path.startswith("/auth"):
|
|
201
|
+
await self.setup_app(scope, receive, send)
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
# Non-setup path: check if setup is complete in DB
|
|
205
|
+
if await self._is_setup_complete_in_db():
|
|
206
|
+
# Setup complete - try to get/create main app
|
|
207
|
+
main_app = await self._get_or_create_main_app()
|
|
208
|
+
if main_app:
|
|
209
|
+
self.setup_locked = True
|
|
210
|
+
await main_app(scope, receive, send)
|
|
211
|
+
else:
|
|
212
|
+
# Can't create main app - show error
|
|
213
|
+
await self._error_response(
|
|
214
|
+
send,
|
|
215
|
+
f"Setup complete but cannot start application: {self._main_app_error}"
|
|
216
|
+
)
|
|
217
|
+
else:
|
|
218
|
+
# Setup not complete - redirect to /setup
|
|
219
|
+
await self._redirect(send, "/setup")
|
|
220
|
+
|
|
221
|
+
async def _is_setup_complete_in_db(self) -> bool:
|
|
222
|
+
"""Check if setup is complete in the database."""
|
|
223
|
+
db_url = self._db_url
|
|
224
|
+
|
|
225
|
+
# Try to get db_url dynamically if not set at startup
|
|
226
|
+
# (setup may have configured the database after server started)
|
|
227
|
+
if not db_url:
|
|
228
|
+
try:
|
|
229
|
+
from skrift.setup.state import get_database_url_from_yaml
|
|
230
|
+
db_url = get_database_url_from_yaml()
|
|
231
|
+
if db_url:
|
|
232
|
+
self._db_url = db_url # Cache for future requests
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
if not db_url:
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
return await check_setup_in_db(db_url)
|
|
241
|
+
except Exception:
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
async def _redirect(self, send: Send, location: str) -> None:
|
|
245
|
+
"""Send a redirect response."""
|
|
246
|
+
await send({
|
|
247
|
+
"type": "http.response.start",
|
|
248
|
+
"status": 302,
|
|
249
|
+
"headers": [(b"location", location.encode()), (b"content-length", b"0")],
|
|
250
|
+
})
|
|
251
|
+
await send({"type": "http.response.body", "body": b""})
|
|
252
|
+
|
|
253
|
+
async def _error_response(self, send: Send, message: str) -> None:
|
|
254
|
+
"""Send an error response."""
|
|
255
|
+
body = f"<h1>Application Error</h1><p>{message}</p><p>Please check your configuration and restart the server.</p>".encode()
|
|
256
|
+
await send({
|
|
257
|
+
"type": "http.response.start",
|
|
258
|
+
"status": 500,
|
|
259
|
+
"headers": [
|
|
260
|
+
(b"content-type", b"text/html"),
|
|
261
|
+
(b"content-length", str(len(body)).encode()),
|
|
262
|
+
],
|
|
263
|
+
})
|
|
264
|
+
await send({"type": "http.response.body", "body": body})
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def create_app() -> Litestar:
|
|
268
|
+
"""Create and configure the main Litestar application.
|
|
269
|
+
|
|
270
|
+
This app has all routes for normal operation. It is used by the dispatcher
|
|
271
|
+
after setup is complete.
|
|
272
|
+
"""
|
|
273
|
+
settings = get_settings()
|
|
274
|
+
|
|
275
|
+
# Load controllers from app.yaml
|
|
276
|
+
controllers = load_controllers()
|
|
277
|
+
|
|
278
|
+
# Database configuration
|
|
279
|
+
if "sqlite" in settings.db.url:
|
|
280
|
+
engine_config = EngineConfig(echo=settings.db.echo)
|
|
281
|
+
else:
|
|
282
|
+
engine_config = EngineConfig(
|
|
283
|
+
pool_size=settings.db.pool_size,
|
|
284
|
+
max_overflow=settings.db.pool_overflow,
|
|
285
|
+
pool_timeout=settings.db.pool_timeout,
|
|
286
|
+
echo=settings.db.echo,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
db_config = SQLAlchemyAsyncConfig(
|
|
290
|
+
connection_string=settings.db.url,
|
|
291
|
+
metadata=Base.metadata,
|
|
292
|
+
create_all=False,
|
|
293
|
+
session_config=AsyncSessionConfig(expire_on_commit=False),
|
|
294
|
+
engine_config=engine_config,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Session configuration (client-side encrypted cookies)
|
|
298
|
+
session_secret = hashlib.sha256(settings.secret_key.encode()).digest()
|
|
299
|
+
session_config = CookieBackendConfig(
|
|
300
|
+
secret=session_secret,
|
|
301
|
+
max_age=60 * 60 * 24 * 7, # 7 days
|
|
302
|
+
httponly=True,
|
|
303
|
+
secure=not settings.debug,
|
|
304
|
+
samesite="lax",
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Template configuration
|
|
308
|
+
template_dir = Path(__file__).parent / "templates"
|
|
309
|
+
template_config = TemplateConfig(
|
|
310
|
+
directory=template_dir,
|
|
311
|
+
engine=JinjaTemplateEngine,
|
|
312
|
+
engine_callback=lambda engine: engine.engine.globals.update({
|
|
313
|
+
"now": datetime.now,
|
|
314
|
+
"site_name": get_cached_site_name,
|
|
315
|
+
"site_tagline": get_cached_site_tagline,
|
|
316
|
+
"site_copyright_holder": get_cached_site_copyright_holder,
|
|
317
|
+
"site_copyright_start_year": get_cached_site_copyright_start_year,
|
|
318
|
+
}),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Static files
|
|
322
|
+
static_files_router = create_static_files_router(
|
|
323
|
+
path="/static",
|
|
324
|
+
directories=[Path(__file__).parent / "static"],
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
from skrift.auth import sync_roles_to_database
|
|
328
|
+
|
|
329
|
+
async def on_startup(_app: Litestar) -> None:
|
|
330
|
+
"""Sync roles and load site settings on startup."""
|
|
331
|
+
try:
|
|
332
|
+
async with db_config.get_session() as session:
|
|
333
|
+
await sync_roles_to_database(session)
|
|
334
|
+
await load_site_settings_cache(session)
|
|
335
|
+
except Exception:
|
|
336
|
+
# Database might not exist yet during setup
|
|
337
|
+
pass
|
|
338
|
+
|
|
339
|
+
return Litestar(
|
|
340
|
+
on_startup=[on_startup],
|
|
341
|
+
route_handlers=[*controllers, static_files_router],
|
|
342
|
+
plugins=[SQLAlchemyPlugin(config=db_config)],
|
|
343
|
+
middleware=[session_config.middleware],
|
|
344
|
+
template_config=template_config,
|
|
345
|
+
compression_config=CompressionConfig(backend="gzip"),
|
|
346
|
+
exception_handlers={
|
|
347
|
+
HTTPException: http_exception_handler,
|
|
348
|
+
Exception: internal_server_error_handler,
|
|
349
|
+
},
|
|
350
|
+
debug=settings.debug,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def create_setup_app() -> Litestar:
|
|
355
|
+
"""Create an application for the setup wizard.
|
|
356
|
+
|
|
357
|
+
This app handles only setup routes (/setup/*, /auth/*, /static/*).
|
|
358
|
+
The AppDispatcher handles routing non-setup paths.
|
|
359
|
+
"""
|
|
360
|
+
from pydantic_settings import BaseSettings
|
|
361
|
+
from skrift.setup.state import get_database_url_from_yaml
|
|
362
|
+
|
|
363
|
+
class MinimalSettings(BaseSettings):
|
|
364
|
+
debug: bool = True
|
|
365
|
+
secret_key: str = "setup-wizard-temporary-secret-key-change-me"
|
|
366
|
+
|
|
367
|
+
settings = MinimalSettings()
|
|
368
|
+
|
|
369
|
+
# Session configuration
|
|
370
|
+
session_secret = hashlib.sha256(settings.secret_key.encode()).digest()
|
|
371
|
+
session_config = CookieBackendConfig(
|
|
372
|
+
secret=session_secret,
|
|
373
|
+
max_age=60 * 60 * 24, # 1 day
|
|
374
|
+
httponly=True,
|
|
375
|
+
secure=False,
|
|
376
|
+
samesite="lax",
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# Template configuration
|
|
380
|
+
template_dir = Path(__file__).parent / "templates"
|
|
381
|
+
template_config = TemplateConfig(
|
|
382
|
+
directory=template_dir,
|
|
383
|
+
engine=JinjaTemplateEngine,
|
|
384
|
+
engine_callback=lambda engine: engine.engine.globals.update({
|
|
385
|
+
"now": datetime.now,
|
|
386
|
+
"site_name": lambda: "Skrift",
|
|
387
|
+
"site_tagline": lambda: "Setup",
|
|
388
|
+
"site_copyright_holder": lambda: "",
|
|
389
|
+
"site_copyright_start_year": lambda: None,
|
|
390
|
+
}),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
# Static files
|
|
394
|
+
static_files_router = create_static_files_router(
|
|
395
|
+
path="/static",
|
|
396
|
+
directories=[Path(__file__).parent / "static"],
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
# Import controllers
|
|
400
|
+
from skrift.setup.controller import SetupController, SetupAuthController
|
|
401
|
+
|
|
402
|
+
# Check if database is configured - if so, include SQLAlchemy
|
|
403
|
+
db_url = get_database_url_from_yaml()
|
|
404
|
+
|
|
405
|
+
# Also try to get the raw db URL from config (before env var resolution)
|
|
406
|
+
if not db_url:
|
|
407
|
+
config_path = Path.cwd() / "app.yaml"
|
|
408
|
+
if config_path.exists():
|
|
409
|
+
try:
|
|
410
|
+
with open(config_path, "r") as f:
|
|
411
|
+
raw_config = yaml.safe_load(f)
|
|
412
|
+
raw_db_url = raw_config.get("db", {}).get("url", "")
|
|
413
|
+
# If it's an env var reference but env var isn't set,
|
|
414
|
+
# check if there's a local SQLite fallback we can use
|
|
415
|
+
if raw_db_url.startswith("$"):
|
|
416
|
+
for db_file in ["./app.db", "./data.db", "./skrift.db"]:
|
|
417
|
+
if Path(db_file).exists():
|
|
418
|
+
db_url = f"sqlite+aiosqlite:///{db_file}"
|
|
419
|
+
break
|
|
420
|
+
except Exception:
|
|
421
|
+
pass
|
|
422
|
+
|
|
423
|
+
plugins = []
|
|
424
|
+
route_handlers = [SetupController, SetupAuthController, static_files_router]
|
|
425
|
+
db_config: SQLAlchemyAsyncConfig | None = None
|
|
426
|
+
|
|
427
|
+
if db_url:
|
|
428
|
+
# Database is configured, add SQLAlchemy plugin
|
|
429
|
+
if "sqlite" in db_url:
|
|
430
|
+
engine_config = EngineConfig(echo=False)
|
|
431
|
+
else:
|
|
432
|
+
engine_config = EngineConfig(
|
|
433
|
+
pool_size=5,
|
|
434
|
+
max_overflow=10,
|
|
435
|
+
pool_timeout=30,
|
|
436
|
+
echo=False,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
db_config = SQLAlchemyAsyncConfig(
|
|
440
|
+
connection_string=db_url,
|
|
441
|
+
metadata=Base.metadata,
|
|
442
|
+
create_all=False,
|
|
443
|
+
session_config=AsyncSessionConfig(expire_on_commit=False),
|
|
444
|
+
engine_config=engine_config,
|
|
445
|
+
)
|
|
446
|
+
plugins.append(SQLAlchemyPlugin(config=db_config))
|
|
447
|
+
|
|
448
|
+
async def on_startup(_app: Litestar) -> None:
|
|
449
|
+
"""Initialize setup state and sync roles if database is available."""
|
|
450
|
+
if db_config is not None:
|
|
451
|
+
try:
|
|
452
|
+
from skrift.auth import sync_roles_to_database
|
|
453
|
+
async with db_config.get_session() as session:
|
|
454
|
+
await sync_roles_to_database(session)
|
|
455
|
+
except Exception:
|
|
456
|
+
pass
|
|
457
|
+
|
|
458
|
+
return Litestar(
|
|
459
|
+
on_startup=[on_startup],
|
|
460
|
+
route_handlers=route_handlers,
|
|
461
|
+
plugins=plugins,
|
|
462
|
+
middleware=[session_config.middleware],
|
|
463
|
+
template_config=template_config,
|
|
464
|
+
compression_config=CompressionConfig(backend="gzip"),
|
|
465
|
+
exception_handlers={
|
|
466
|
+
HTTPException: http_exception_handler,
|
|
467
|
+
Exception: internal_server_error_handler,
|
|
468
|
+
},
|
|
469
|
+
debug=settings.debug,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
async def check_setup_in_db(db_url: str) -> bool:
|
|
474
|
+
"""Check if setup is complete by querying the database directly."""
|
|
475
|
+
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
|
476
|
+
|
|
477
|
+
engine = create_async_engine(db_url)
|
|
478
|
+
async_session = async_sessionmaker(engine, expire_on_commit=False)
|
|
479
|
+
|
|
480
|
+
try:
|
|
481
|
+
async with async_session() as session:
|
|
482
|
+
value = await get_setting(session, SETUP_COMPLETED_AT_KEY)
|
|
483
|
+
return value is not None
|
|
484
|
+
except Exception:
|
|
485
|
+
return False
|
|
486
|
+
finally:
|
|
487
|
+
await engine.dispose()
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def create_dispatcher() -> ASGIApp:
|
|
491
|
+
"""Create the ASGI app dispatcher.
|
|
492
|
+
|
|
493
|
+
This is the main entry point. The dispatcher handles routing between
|
|
494
|
+
setup and main apps, with lazy creation of the main app after setup completes.
|
|
495
|
+
"""
|
|
496
|
+
global _dispatcher
|
|
497
|
+
from skrift.setup.state import get_database_url_from_yaml
|
|
498
|
+
|
|
499
|
+
# Get database URL first
|
|
500
|
+
db_url: str | None = None
|
|
501
|
+
try:
|
|
502
|
+
db_url = get_database_url_from_yaml()
|
|
503
|
+
except Exception:
|
|
504
|
+
pass
|
|
505
|
+
|
|
506
|
+
# Check if setup is already complete
|
|
507
|
+
initial_setup_complete = False
|
|
508
|
+
if db_url:
|
|
509
|
+
try:
|
|
510
|
+
initial_setup_complete = asyncio.get_event_loop().run_until_complete(
|
|
511
|
+
check_setup_in_db(db_url)
|
|
512
|
+
)
|
|
513
|
+
except RuntimeError:
|
|
514
|
+
# No running event loop, try creating one
|
|
515
|
+
try:
|
|
516
|
+
initial_setup_complete = asyncio.run(check_setup_in_db(db_url))
|
|
517
|
+
except Exception:
|
|
518
|
+
pass
|
|
519
|
+
except Exception:
|
|
520
|
+
pass
|
|
521
|
+
|
|
522
|
+
# Also check if config is valid
|
|
523
|
+
config_valid, _ = is_config_valid()
|
|
524
|
+
|
|
525
|
+
if initial_setup_complete and config_valid:
|
|
526
|
+
# Setup already done - just return the main app directly
|
|
527
|
+
return create_app()
|
|
528
|
+
|
|
529
|
+
# Try to create main app if config is valid
|
|
530
|
+
main_app: Litestar | None = None
|
|
531
|
+
if config_valid:
|
|
532
|
+
try:
|
|
533
|
+
main_app = create_app()
|
|
534
|
+
except Exception:
|
|
535
|
+
pass
|
|
536
|
+
|
|
537
|
+
# Always use dispatcher - it handles lazy main app creation
|
|
538
|
+
setup_app = create_setup_app()
|
|
539
|
+
dispatcher = AppDispatcher(setup_app=setup_app, db_url=db_url, main_app=main_app)
|
|
540
|
+
dispatcher.setup_locked = initial_setup_complete
|
|
541
|
+
_dispatcher = dispatcher # Store reference for later updates
|
|
542
|
+
return dispatcher
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
app = create_dispatcher()
|
skrift/auth/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Authentication and authorization module."""
|
|
2
|
+
|
|
3
|
+
from skrift.auth.guards import (
|
|
4
|
+
ADMINISTRATOR_PERMISSION,
|
|
5
|
+
AndRequirement,
|
|
6
|
+
AuthRequirement,
|
|
7
|
+
OrRequirement,
|
|
8
|
+
Permission,
|
|
9
|
+
Role,
|
|
10
|
+
auth_guard,
|
|
11
|
+
)
|
|
12
|
+
from skrift.auth.roles import (
|
|
13
|
+
ADMIN,
|
|
14
|
+
AUTHOR,
|
|
15
|
+
EDITOR,
|
|
16
|
+
MODERATOR,
|
|
17
|
+
ROLE_DEFINITIONS,
|
|
18
|
+
RoleDefinition,
|
|
19
|
+
create_role,
|
|
20
|
+
get_role_definition,
|
|
21
|
+
register_role,
|
|
22
|
+
)
|
|
23
|
+
from skrift.auth.services import (
|
|
24
|
+
UserPermissions,
|
|
25
|
+
assign_role_to_user,
|
|
26
|
+
get_user_permissions,
|
|
27
|
+
invalidate_user_permissions_cache,
|
|
28
|
+
remove_role_from_user,
|
|
29
|
+
sync_roles_to_database,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
# Guards
|
|
34
|
+
"ADMINISTRATOR_PERMISSION",
|
|
35
|
+
"AndRequirement",
|
|
36
|
+
"AuthRequirement",
|
|
37
|
+
"OrRequirement",
|
|
38
|
+
"Permission",
|
|
39
|
+
"Role",
|
|
40
|
+
"auth_guard",
|
|
41
|
+
# Roles
|
|
42
|
+
"ADMIN",
|
|
43
|
+
"AUTHOR",
|
|
44
|
+
"EDITOR",
|
|
45
|
+
"MODERATOR",
|
|
46
|
+
"ROLE_DEFINITIONS",
|
|
47
|
+
"RoleDefinition",
|
|
48
|
+
"create_role",
|
|
49
|
+
"get_role_definition",
|
|
50
|
+
"register_role",
|
|
51
|
+
# Services
|
|
52
|
+
"UserPermissions",
|
|
53
|
+
"assign_role_to_user",
|
|
54
|
+
"get_user_permissions",
|
|
55
|
+
"invalidate_user_permissions_cache",
|
|
56
|
+
"remove_role_from_user",
|
|
57
|
+
"sync_roles_to_database",
|
|
58
|
+
]
|