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.
- skrift/__init__.py +1 -0
- skrift/__main__.py +12 -0
- skrift/admin/__init__.py +11 -0
- skrift/admin/controller.py +452 -0
- skrift/admin/navigation.py +105 -0
- skrift/alembic/env.py +92 -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/versions/20260129_add_oauth_accounts.py +141 -0
- skrift/alembic/versions/20260129_add_provider_metadata.py +29 -0
- skrift/alembic.ini +77 -0
- skrift/asgi.py +670 -0
- skrift/auth/__init__.py +58 -0
- skrift/auth/guards.py +130 -0
- skrift/auth/roles.py +129 -0
- skrift/auth/services.py +184 -0
- skrift/cli.py +143 -0
- skrift/config.py +259 -0
- skrift/controllers/__init__.py +4 -0
- skrift/controllers/auth.py +595 -0
- skrift/controllers/web.py +67 -0
- skrift/db/__init__.py +3 -0
- skrift/db/base.py +7 -0
- skrift/db/models/__init__.py +7 -0
- skrift/db/models/oauth_account.py +50 -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/oauth_service.py +195 -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 +213 -0
- skrift/setup/controller.py +888 -0
- skrift/setup/middleware.py +89 -0
- skrift/setup/providers.py +214 -0
- skrift/setup/state.py +315 -0
- skrift/static/css/style.css +1003 -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/dummy_login.html +102 -0
- skrift/templates/auth/login.html +139 -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/configuring.html +158 -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.0a12.dist-info/METADATA +235 -0
- skrift-0.1.0a12.dist-info/RECORD +74 -0
- skrift-0.1.0a12.dist-info/WHEEL +4 -0
- skrift-0.1.0a12.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Setup wizard middleware for redirecting users to the setup wizard."""
|
|
2
|
+
|
|
3
|
+
from litestar.response import Redirect
|
|
4
|
+
from litestar.types import ASGIApp, Receive, Scope, Send
|
|
5
|
+
|
|
6
|
+
# Paths that should be accessible during setup
|
|
7
|
+
SETUP_ALLOWED_PATHS = (
|
|
8
|
+
"/setup",
|
|
9
|
+
"/static",
|
|
10
|
+
"/auth", # Needed for OAuth callbacks during admin creation
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SetupMiddleware:
|
|
15
|
+
"""ASGI middleware to redirect users to setup wizard if not configured."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, app: ASGIApp, setup_complete: bool = False, use_app_state: bool = False) -> None:
|
|
18
|
+
"""Initialize the middleware.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
app: The ASGI application
|
|
22
|
+
setup_complete: Whether setup has been completed (static mode)
|
|
23
|
+
use_app_state: Whether to check app.state.setup_complete at runtime
|
|
24
|
+
"""
|
|
25
|
+
self.app = app
|
|
26
|
+
self._setup_complete = setup_complete
|
|
27
|
+
self._use_app_state = use_app_state
|
|
28
|
+
|
|
29
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
30
|
+
"""Process the request."""
|
|
31
|
+
if scope["type"] != "http":
|
|
32
|
+
await self.app(scope, receive, send)
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
path = scope["path"]
|
|
36
|
+
|
|
37
|
+
# Check setup complete status - either from app state (dynamic) or instance var (static)
|
|
38
|
+
setup_complete = self._setup_complete
|
|
39
|
+
if self._use_app_state and "app" in scope:
|
|
40
|
+
litestar_app = scope["app"]
|
|
41
|
+
setup_complete = getattr(litestar_app.state, "setup_complete", self._setup_complete)
|
|
42
|
+
|
|
43
|
+
# If setup is complete, block access to /setup
|
|
44
|
+
if setup_complete:
|
|
45
|
+
if path.startswith("/setup"):
|
|
46
|
+
# Redirect setup routes to home after setup is complete
|
|
47
|
+
response = Redirect(path="/")
|
|
48
|
+
await response(scope, receive, send)
|
|
49
|
+
return
|
|
50
|
+
await self.app(scope, receive, send)
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
# Setup not complete - check if path is allowed
|
|
54
|
+
if any(path.startswith(allowed) for allowed in SETUP_ALLOWED_PATHS):
|
|
55
|
+
await self.app(scope, receive, send)
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
# Redirect to setup wizard
|
|
59
|
+
response = Redirect(path="/setup")
|
|
60
|
+
await response(scope, receive, send)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def create_setup_middleware_factory(setup_complete: bool):
|
|
64
|
+
"""Create a middleware factory for the setup middleware (static mode).
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
setup_complete: Whether setup has been completed
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Middleware factory function
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def factory(app: ASGIApp) -> SetupMiddleware:
|
|
74
|
+
return SetupMiddleware(app, setup_complete=setup_complete)
|
|
75
|
+
|
|
76
|
+
return factory
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def create_dynamic_setup_middleware_factory():
|
|
80
|
+
"""Create a middleware factory that checks app state at runtime.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Middleware factory function
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def factory(app: ASGIApp) -> SetupMiddleware:
|
|
87
|
+
return SetupMiddleware(app, setup_complete=False, use_app_state=True)
|
|
88
|
+
|
|
89
|
+
return factory
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""OAuth provider definitions and configuration for the setup wizard."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
DUMMY_PROVIDER_KEY = "dummy"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class OAuthProviderInfo:
|
|
10
|
+
"""Information about an OAuth provider."""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
auth_url: str
|
|
14
|
+
token_url: str
|
|
15
|
+
userinfo_url: str
|
|
16
|
+
scopes: list[str]
|
|
17
|
+
console_url: str
|
|
18
|
+
fields: list[dict]
|
|
19
|
+
instructions: str
|
|
20
|
+
icon: str = ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
OAUTH_PROVIDERS = {
|
|
24
|
+
"google": OAuthProviderInfo(
|
|
25
|
+
name="Google",
|
|
26
|
+
auth_url="https://accounts.google.com/o/oauth2/v2/auth",
|
|
27
|
+
token_url="https://oauth2.googleapis.com/token",
|
|
28
|
+
userinfo_url="https://www.googleapis.com/oauth2/v2/userinfo",
|
|
29
|
+
scopes=["openid", "email", "profile"],
|
|
30
|
+
console_url="https://console.cloud.google.com/apis/credentials",
|
|
31
|
+
fields=[
|
|
32
|
+
{"key": "client_id", "label": "Client ID", "type": "text"},
|
|
33
|
+
{"key": "client_secret", "label": "Client Secret", "type": "password"},
|
|
34
|
+
],
|
|
35
|
+
instructions="""
|
|
36
|
+
1. Go to the Google Cloud Console
|
|
37
|
+
2. Create a new project or select an existing one
|
|
38
|
+
3. Enable the Google+ API
|
|
39
|
+
4. Go to Credentials → Create Credentials → OAuth Client ID
|
|
40
|
+
5. Choose "Web application"
|
|
41
|
+
6. Add the redirect URI shown below
|
|
42
|
+
7. Copy the Client ID and Client Secret
|
|
43
|
+
""".strip(),
|
|
44
|
+
icon="google",
|
|
45
|
+
),
|
|
46
|
+
"github": OAuthProviderInfo(
|
|
47
|
+
name="GitHub",
|
|
48
|
+
auth_url="https://github.com/login/oauth/authorize",
|
|
49
|
+
token_url="https://github.com/login/oauth/access_token",
|
|
50
|
+
userinfo_url="https://api.github.com/user",
|
|
51
|
+
scopes=["read:user", "user:email"],
|
|
52
|
+
console_url="https://github.com/settings/developers",
|
|
53
|
+
fields=[
|
|
54
|
+
{"key": "client_id", "label": "Client ID", "type": "text"},
|
|
55
|
+
{"key": "client_secret", "label": "Client Secret", "type": "password"},
|
|
56
|
+
],
|
|
57
|
+
instructions="""
|
|
58
|
+
1. Go to GitHub Settings → Developer settings → OAuth Apps
|
|
59
|
+
2. Click "New OAuth App"
|
|
60
|
+
3. Fill in the application details
|
|
61
|
+
4. Add the redirect URI shown below as the callback URL
|
|
62
|
+
5. Copy the Client ID and generate a Client Secret
|
|
63
|
+
""".strip(),
|
|
64
|
+
icon="github",
|
|
65
|
+
),
|
|
66
|
+
"microsoft": OAuthProviderInfo(
|
|
67
|
+
name="Microsoft",
|
|
68
|
+
auth_url="https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
|
|
69
|
+
token_url="https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
|
|
70
|
+
userinfo_url="https://graph.microsoft.com/v1.0/me",
|
|
71
|
+
scopes=["openid", "email", "profile"],
|
|
72
|
+
console_url="https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
|
|
73
|
+
fields=[
|
|
74
|
+
{"key": "client_id", "label": "Client (Application) ID", "type": "text"},
|
|
75
|
+
{"key": "client_secret", "label": "Client Secret", "type": "password"},
|
|
76
|
+
{
|
|
77
|
+
"key": "tenant_id",
|
|
78
|
+
"label": "Tenant ID",
|
|
79
|
+
"type": "text",
|
|
80
|
+
"placeholder": "common",
|
|
81
|
+
"optional": True,
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
instructions="""
|
|
85
|
+
1. Go to Azure Portal → App registrations
|
|
86
|
+
2. Click "New registration"
|
|
87
|
+
3. Enter a name and select the account types you want to support
|
|
88
|
+
4. Add the redirect URI shown below (Web platform)
|
|
89
|
+
5. Under Certificates & secrets, create a new client secret
|
|
90
|
+
6. Copy the Application (client) ID and secret value
|
|
91
|
+
7. For Tenant ID: use "common" for any Microsoft account, or your specific tenant ID
|
|
92
|
+
""".strip(),
|
|
93
|
+
icon="microsoft",
|
|
94
|
+
),
|
|
95
|
+
"discord": OAuthProviderInfo(
|
|
96
|
+
name="Discord",
|
|
97
|
+
auth_url="https://discord.com/api/oauth2/authorize",
|
|
98
|
+
token_url="https://discord.com/api/oauth2/token",
|
|
99
|
+
userinfo_url="https://discord.com/api/users/@me",
|
|
100
|
+
scopes=["identify", "email"],
|
|
101
|
+
console_url="https://discord.com/developers/applications",
|
|
102
|
+
fields=[
|
|
103
|
+
{"key": "client_id", "label": "Client ID", "type": "text"},
|
|
104
|
+
{"key": "client_secret", "label": "Client Secret", "type": "password"},
|
|
105
|
+
],
|
|
106
|
+
instructions="""
|
|
107
|
+
1. Go to Discord Developer Portal
|
|
108
|
+
2. Create a new application
|
|
109
|
+
3. Go to OAuth2 → General
|
|
110
|
+
4. Add the redirect URI shown below
|
|
111
|
+
5. Copy the Client ID and Client Secret
|
|
112
|
+
""".strip(),
|
|
113
|
+
icon="discord",
|
|
114
|
+
),
|
|
115
|
+
"facebook": OAuthProviderInfo(
|
|
116
|
+
name="Facebook",
|
|
117
|
+
auth_url="https://www.facebook.com/v18.0/dialog/oauth",
|
|
118
|
+
token_url="https://graph.facebook.com/v18.0/oauth/access_token",
|
|
119
|
+
userinfo_url="https://graph.facebook.com/me?fields=id,name,email,picture",
|
|
120
|
+
scopes=["email", "public_profile"],
|
|
121
|
+
console_url="https://developers.facebook.com/apps/",
|
|
122
|
+
fields=[
|
|
123
|
+
{"key": "client_id", "label": "App ID", "type": "text"},
|
|
124
|
+
{"key": "client_secret", "label": "App Secret", "type": "password"},
|
|
125
|
+
],
|
|
126
|
+
instructions="""
|
|
127
|
+
1. Go to Meta for Developers
|
|
128
|
+
2. Create a new app (Consumer type)
|
|
129
|
+
3. Add Facebook Login product
|
|
130
|
+
4. Go to Settings → Basic to find App ID and Secret
|
|
131
|
+
5. Add the redirect URI shown below in Facebook Login settings
|
|
132
|
+
""".strip(),
|
|
133
|
+
icon="facebook",
|
|
134
|
+
),
|
|
135
|
+
"twitter": OAuthProviderInfo(
|
|
136
|
+
name="X (Twitter)",
|
|
137
|
+
auth_url="https://twitter.com/i/oauth2/authorize",
|
|
138
|
+
token_url="https://api.twitter.com/2/oauth2/token",
|
|
139
|
+
userinfo_url="https://api.twitter.com/2/users/me",
|
|
140
|
+
scopes=["users.read", "tweet.read"],
|
|
141
|
+
console_url="https://developer.twitter.com/en/portal/dashboard",
|
|
142
|
+
fields=[
|
|
143
|
+
{"key": "client_id", "label": "Client ID", "type": "text"},
|
|
144
|
+
{"key": "client_secret", "label": "Client Secret", "type": "password"},
|
|
145
|
+
],
|
|
146
|
+
instructions="""
|
|
147
|
+
1. Go to X Developer Portal
|
|
148
|
+
2. Create a new project and app
|
|
149
|
+
3. Enable OAuth 2.0 in User authentication settings
|
|
150
|
+
4. Add the redirect URI shown below
|
|
151
|
+
5. Copy the Client ID and Client Secret (OAuth 2.0)
|
|
152
|
+
""".strip(),
|
|
153
|
+
icon="twitter",
|
|
154
|
+
),
|
|
155
|
+
DUMMY_PROVIDER_KEY: OAuthProviderInfo(
|
|
156
|
+
name="Dummy (Development Only)",
|
|
157
|
+
auth_url="",
|
|
158
|
+
token_url="",
|
|
159
|
+
userinfo_url="",
|
|
160
|
+
scopes=[],
|
|
161
|
+
console_url="",
|
|
162
|
+
fields=[],
|
|
163
|
+
instructions="Development-only provider. DO NOT use in production.",
|
|
164
|
+
icon="dummy",
|
|
165
|
+
),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def get_provider_info(provider: str) -> OAuthProviderInfo | None:
|
|
170
|
+
"""Get provider info by key."""
|
|
171
|
+
return OAUTH_PROVIDERS.get(provider)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def get_all_providers() -> dict[str, OAuthProviderInfo]:
|
|
175
|
+
"""Get all available OAuth providers (excluding dev-only providers)."""
|
|
176
|
+
return {k: v for k, v in OAUTH_PROVIDERS.items() if k != DUMMY_PROVIDER_KEY}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def validate_no_dummy_auth_in_production() -> None:
|
|
180
|
+
"""Exit process if dummy auth is configured in production."""
|
|
181
|
+
import os
|
|
182
|
+
import signal
|
|
183
|
+
import sys
|
|
184
|
+
|
|
185
|
+
from skrift.config import get_environment, load_raw_app_config
|
|
186
|
+
|
|
187
|
+
if get_environment() != "production":
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
config = load_raw_app_config()
|
|
191
|
+
if config is None:
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
providers = config.get("auth", {}).get("providers", {})
|
|
195
|
+
if DUMMY_PROVIDER_KEY in providers:
|
|
196
|
+
# Only print if we haven't already (use env var as cross-process flag)
|
|
197
|
+
if not os.environ.get("_SKRIFT_DUMMY_ERROR_PRINTED"):
|
|
198
|
+
os.environ["_SKRIFT_DUMMY_ERROR_PRINTED"] = "1"
|
|
199
|
+
sys.stderr.write(
|
|
200
|
+
"\n"
|
|
201
|
+
"======================================================================\n"
|
|
202
|
+
"SECURITY ERROR: Dummy auth provider is configured in production.\n"
|
|
203
|
+
"Remove 'dummy' from auth.providers in app.yaml.\n"
|
|
204
|
+
"Server will NOT start.\n"
|
|
205
|
+
"======================================================================\n\n"
|
|
206
|
+
)
|
|
207
|
+
sys.stderr.flush()
|
|
208
|
+
|
|
209
|
+
# Kill parent process (uvicorn reloader) to stop respawning
|
|
210
|
+
try:
|
|
211
|
+
os.kill(os.getppid(), signal.SIGTERM)
|
|
212
|
+
except (ProcessLookupError, PermissionError):
|
|
213
|
+
pass
|
|
214
|
+
os._exit(1)
|
skrift/setup/state.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Setup state detection for the Skrift setup wizard.
|
|
2
|
+
|
|
3
|
+
This module implements a two-tier detection strategy:
|
|
4
|
+
1. Pre-database check: Can we connect to a database?
|
|
5
|
+
2. Post-database check: Is setup complete (check for setup_completed_at setting)?
|
|
6
|
+
|
|
7
|
+
Smart step detection: If config is already present, skip to the first incomplete step.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import yaml
|
|
15
|
+
from sqlalchemy import text
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
|
17
|
+
|
|
18
|
+
# Track if migrations have been run this session to avoid running multiple times
|
|
19
|
+
_migrations_run = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def reset_migrations_flag() -> None:
|
|
23
|
+
"""Reset the migrations flag to allow re-running migrations.
|
|
24
|
+
|
|
25
|
+
Call this when starting the configuring page to ensure migrations run fresh.
|
|
26
|
+
"""
|
|
27
|
+
global _migrations_run
|
|
28
|
+
_migrations_run = False
|
|
29
|
+
|
|
30
|
+
from skrift.config import get_config_path
|
|
31
|
+
from skrift.db.services.setting_service import (
|
|
32
|
+
SETUP_COMPLETED_AT_KEY,
|
|
33
|
+
SITE_NAME_KEY,
|
|
34
|
+
get_setting,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SetupStep(Enum):
|
|
39
|
+
"""Wizard steps."""
|
|
40
|
+
|
|
41
|
+
DATABASE = "database"
|
|
42
|
+
AUTH = "auth"
|
|
43
|
+
SITE = "site"
|
|
44
|
+
ADMIN = "admin"
|
|
45
|
+
COMPLETE = "complete"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def app_yaml_exists() -> bool:
|
|
49
|
+
"""Check if app.yaml exists in the current working directory."""
|
|
50
|
+
return get_config_path().exists()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_database_url_from_yaml() -> str | None:
|
|
54
|
+
"""Try to get the database URL from app.yaml, returning None if not configured.
|
|
55
|
+
|
|
56
|
+
If the URL is an env var reference that isn't set, falls back to checking
|
|
57
|
+
for local SQLite database files.
|
|
58
|
+
"""
|
|
59
|
+
import yaml
|
|
60
|
+
|
|
61
|
+
config_path = get_config_path()
|
|
62
|
+
if not config_path.exists():
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
with open(config_path, "r") as f:
|
|
67
|
+
config = yaml.safe_load(f)
|
|
68
|
+
|
|
69
|
+
if not config or "db" not in config:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
db_url = config["db"].get("url")
|
|
73
|
+
if not db_url:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
# If it's an env var reference, try to resolve it
|
|
77
|
+
if db_url.startswith("$"):
|
|
78
|
+
env_var = db_url[1:]
|
|
79
|
+
resolved = os.environ.get(env_var)
|
|
80
|
+
if resolved:
|
|
81
|
+
return resolved
|
|
82
|
+
|
|
83
|
+
# Fallback: check for local SQLite database files
|
|
84
|
+
for db_file in ["./app.db", "./data.db", "./skrift.db"]:
|
|
85
|
+
if Path(db_file).exists():
|
|
86
|
+
return f"sqlite+aiosqlite:///{db_file}"
|
|
87
|
+
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
return db_url
|
|
91
|
+
except Exception:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def can_connect_to_database() -> tuple[bool, str | None]:
|
|
96
|
+
"""Test if we can connect to the database.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Tuple of (success, error_message)
|
|
100
|
+
"""
|
|
101
|
+
db_url = get_database_url_from_yaml()
|
|
102
|
+
if not db_url:
|
|
103
|
+
return False, "Database URL not configured"
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
engine = create_async_engine(db_url)
|
|
107
|
+
async with engine.connect() as conn:
|
|
108
|
+
await conn.execute(text("SELECT 1"))
|
|
109
|
+
await engine.dispose()
|
|
110
|
+
return True, None
|
|
111
|
+
except Exception as e:
|
|
112
|
+
return False, str(e)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def is_setup_complete(db_session: AsyncSession) -> bool:
|
|
116
|
+
"""Check if setup has been completed by looking for the setup_completed_at setting."""
|
|
117
|
+
try:
|
|
118
|
+
value = await get_setting(db_session, SETUP_COMPLETED_AT_KEY)
|
|
119
|
+
return value is not None
|
|
120
|
+
except Exception:
|
|
121
|
+
# Table might not exist yet
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_auth_configured() -> bool:
|
|
126
|
+
"""Check if at least one OAuth provider is fully configured in app.yaml.
|
|
127
|
+
|
|
128
|
+
A provider is considered configured if it has both client_id and client_secret.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
True if at least one provider is configured, False otherwise.
|
|
132
|
+
"""
|
|
133
|
+
config_path = get_config_path()
|
|
134
|
+
if not config_path.exists():
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
with open(config_path, "r") as f:
|
|
139
|
+
config = yaml.safe_load(f)
|
|
140
|
+
|
|
141
|
+
if not config:
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
auth = config.get("auth", {})
|
|
145
|
+
providers = auth.get("providers", {})
|
|
146
|
+
|
|
147
|
+
for _, provider_config in providers.items():
|
|
148
|
+
if not isinstance(provider_config, dict):
|
|
149
|
+
continue
|
|
150
|
+
# Check if provider has both client_id and client_secret (even as env var refs)
|
|
151
|
+
client_id = provider_config.get("client_id", "")
|
|
152
|
+
client_secret = provider_config.get("client_secret", "")
|
|
153
|
+
if client_id and client_secret:
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
return False
|
|
157
|
+
except Exception:
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def run_migrations_if_needed() -> tuple[bool, str | None]:
|
|
162
|
+
"""Run database migrations if they haven't been run this session.
|
|
163
|
+
|
|
164
|
+
This ensures the database schema is up to date before checking for
|
|
165
|
+
settings or other database-dependent configuration.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Tuple of (success, error_message)
|
|
169
|
+
"""
|
|
170
|
+
global _migrations_run
|
|
171
|
+
if _migrations_run:
|
|
172
|
+
return True, None
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
# Try skrift-db first
|
|
176
|
+
result = subprocess.run(
|
|
177
|
+
["skrift-db", "upgrade", "head"],
|
|
178
|
+
capture_output=True,
|
|
179
|
+
text=True,
|
|
180
|
+
cwd=Path.cwd(),
|
|
181
|
+
timeout=60,
|
|
182
|
+
)
|
|
183
|
+
if result.returncode == 0:
|
|
184
|
+
_migrations_run = True
|
|
185
|
+
return True, None
|
|
186
|
+
# If skrift-db fails, try alembic directly
|
|
187
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
result = subprocess.run(
|
|
192
|
+
["alembic", "upgrade", "head"],
|
|
193
|
+
capture_output=True,
|
|
194
|
+
text=True,
|
|
195
|
+
cwd=Path.cwd(),
|
|
196
|
+
timeout=60,
|
|
197
|
+
)
|
|
198
|
+
if result.returncode == 0:
|
|
199
|
+
_migrations_run = True
|
|
200
|
+
return True, None
|
|
201
|
+
return False, result.stderr
|
|
202
|
+
except subprocess.TimeoutExpired:
|
|
203
|
+
return False, "Migration timed out"
|
|
204
|
+
except FileNotFoundError:
|
|
205
|
+
return False, "Neither skrift-db nor alembic found"
|
|
206
|
+
except Exception as e:
|
|
207
|
+
return False, str(e)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def is_site_configured() -> bool:
|
|
211
|
+
"""Check if site settings have been configured in the database.
|
|
212
|
+
|
|
213
|
+
The site step is considered complete if site_name has been set.
|
|
214
|
+
Returns False if the settings table doesn't exist yet (pre-migration).
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
True if site is configured, False otherwise.
|
|
218
|
+
"""
|
|
219
|
+
db_url = get_database_url_from_yaml()
|
|
220
|
+
if not db_url:
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
engine = None
|
|
224
|
+
try:
|
|
225
|
+
engine = create_async_engine(db_url)
|
|
226
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
227
|
+
|
|
228
|
+
async_session = async_sessionmaker(engine, expire_on_commit=False)
|
|
229
|
+
async with async_session() as session:
|
|
230
|
+
try:
|
|
231
|
+
site_name = await get_setting(session, SITE_NAME_KEY)
|
|
232
|
+
return site_name is not None
|
|
233
|
+
except Exception:
|
|
234
|
+
# Table might not exist yet (before migration)
|
|
235
|
+
return False
|
|
236
|
+
except Exception:
|
|
237
|
+
return False
|
|
238
|
+
finally:
|
|
239
|
+
if engine:
|
|
240
|
+
await engine.dispose()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
async def get_first_incomplete_step() -> SetupStep:
|
|
244
|
+
"""Determine the first incomplete step in the setup wizard.
|
|
245
|
+
|
|
246
|
+
This function checks configuration completeness for each step and returns
|
|
247
|
+
the first step that needs to be completed. Use this to skip already-configured
|
|
248
|
+
steps when the user is forced back into the setup wizard.
|
|
249
|
+
|
|
250
|
+
If database is configured and connectable, runs migrations to ensure
|
|
251
|
+
all tables exist before checking database-dependent configuration.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
The first setup step that needs user input.
|
|
255
|
+
"""
|
|
256
|
+
# Step 1: Database - check if we can connect
|
|
257
|
+
if not app_yaml_exists():
|
|
258
|
+
return SetupStep.DATABASE
|
|
259
|
+
|
|
260
|
+
db_url = get_database_url_from_yaml()
|
|
261
|
+
if not db_url:
|
|
262
|
+
return SetupStep.DATABASE
|
|
263
|
+
|
|
264
|
+
can_connect, _ = await can_connect_to_database()
|
|
265
|
+
if not can_connect:
|
|
266
|
+
return SetupStep.DATABASE
|
|
267
|
+
|
|
268
|
+
# Database is configured and connectable - run migrations to ensure tables exist
|
|
269
|
+
migration_success, _ = run_migrations_if_needed()
|
|
270
|
+
if not migration_success:
|
|
271
|
+
# If migrations fail, go back to database step to show the error
|
|
272
|
+
return SetupStep.DATABASE
|
|
273
|
+
|
|
274
|
+
# Step 2: Auth - check if at least one provider is configured
|
|
275
|
+
if not is_auth_configured():
|
|
276
|
+
return SetupStep.AUTH
|
|
277
|
+
|
|
278
|
+
# Step 3: Site - check if site settings exist in DB
|
|
279
|
+
if not await is_site_configured():
|
|
280
|
+
return SetupStep.SITE
|
|
281
|
+
|
|
282
|
+
# Step 4: Admin - always go here if setup not complete
|
|
283
|
+
return SetupStep.ADMIN
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def get_setup_step(db_session: AsyncSession | None = None) -> SetupStep:
|
|
287
|
+
"""Determine which setup step the user should be on.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
db_session: Database session if available
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
The appropriate setup step
|
|
294
|
+
"""
|
|
295
|
+
# Pre-database check
|
|
296
|
+
if not app_yaml_exists():
|
|
297
|
+
return SetupStep.DATABASE
|
|
298
|
+
|
|
299
|
+
db_url = get_database_url_from_yaml()
|
|
300
|
+
if not db_url:
|
|
301
|
+
return SetupStep.DATABASE
|
|
302
|
+
|
|
303
|
+
can_connect, _ = await can_connect_to_database()
|
|
304
|
+
if not can_connect:
|
|
305
|
+
return SetupStep.DATABASE
|
|
306
|
+
|
|
307
|
+
# Post-database check - need a session
|
|
308
|
+
if db_session is None:
|
|
309
|
+
return SetupStep.DATABASE
|
|
310
|
+
|
|
311
|
+
if not await is_setup_complete(db_session):
|
|
312
|
+
# Check wizard progress stored in session (handled by controller)
|
|
313
|
+
return SetupStep.AUTH
|
|
314
|
+
|
|
315
|
+
return SetupStep.COMPLETE
|