skrift 0.1.0a1__py3-none-any.whl → 0.1.0a3__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.
@@ -0,0 +1,37 @@
1
+ """OAuth account model for storing multiple OAuth identities per user."""
2
+
3
+ from typing import TYPE_CHECKING
4
+ from uuid import UUID
5
+
6
+ from sqlalchemy import ForeignKey, String, UniqueConstraint
7
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
8
+
9
+ from skrift.db.base import Base
10
+
11
+ if TYPE_CHECKING:
12
+ from skrift.db.models.user import User
13
+
14
+
15
+ class OAuthAccount(Base):
16
+ """OAuth account model linking OAuth provider identities to users.
17
+
18
+ This allows a single user to have multiple OAuth provider accounts
19
+ linked to their profile, enabling login via different providers.
20
+ """
21
+
22
+ __tablename__ = "oauth_accounts"
23
+
24
+ provider: Mapped[str] = mapped_column(String(50), nullable=False)
25
+ provider_account_id: Mapped[str] = mapped_column(String(255), nullable=False)
26
+ provider_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
27
+ user_id: Mapped[UUID] = mapped_column(
28
+ ForeignKey("users.id", ondelete="CASCADE"), nullable=False
29
+ )
30
+
31
+ user: Mapped["User"] = relationship("User", back_populates="oauth_accounts")
32
+
33
+ __table_args__ = (
34
+ UniqueConstraint(
35
+ "provider", "provider_account_id", name="uq_oauth_provider_account"
36
+ ),
37
+ )
skrift/db/models/user.py CHANGED
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
7
7
  from skrift.db.base import Base
8
8
 
9
9
  if TYPE_CHECKING:
10
+ from skrift.db.models.oauth_account import OAuthAccount
10
11
  from skrift.db.models.page import Page
11
12
  from skrift.db.models.role import Role
12
13
 
@@ -16,12 +17,8 @@ class User(Base):
16
17
 
17
18
  __tablename__ = "users"
18
19
 
19
- # OAuth identifiers
20
- oauth_provider: Mapped[str] = mapped_column(String(50), nullable=False)
21
- oauth_id: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
22
-
23
20
  # Profile data from OAuth provider
24
- email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
21
+ email: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
25
22
  name: Mapped[str | None] = mapped_column(String(255), nullable=True)
26
23
  picture_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
27
24
 
@@ -30,6 +27,9 @@ class User(Base):
30
27
  last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
31
28
 
32
29
  # Relationships
30
+ oauth_accounts: Mapped[list["OAuthAccount"]] = relationship(
31
+ "OAuthAccount", back_populates="user", cascade="all, delete-orphan"
32
+ )
33
33
  pages: Mapped[list["Page"]] = relationship("Page", back_populates="user")
34
34
  roles: Mapped[list["Role"]] = relationship(
35
35
  "Role", secondary="user_roles", back_populates="users", lazy="selectin"
@@ -7,6 +7,8 @@ from typing import Any
7
7
 
8
8
  from ruamel.yaml import YAML
9
9
 
10
+ from skrift.config import get_config_path as _get_config_path
11
+
10
12
  # Default app.yaml structure
11
13
  DEFAULT_CONFIG = {
12
14
  "controllers": [
@@ -29,8 +31,8 @@ DEFAULT_CONFIG = {
29
31
 
30
32
 
31
33
  def get_config_path() -> Path:
32
- """Get the path to app.yaml."""
33
- return Path.cwd() / "app.yaml"
34
+ """Get the path to the current environment's config file."""
35
+ return _get_config_path()
34
36
 
35
37
 
36
38
  def backup_config() -> Path | None:
@@ -1,12 +1,13 @@
1
1
  """Setup wizard controller for first-time Skrift configuration."""
2
2
 
3
+ import asyncio
3
4
  import base64
4
5
  import hashlib
6
+ import json
5
7
  import secrets
6
- import subprocess
8
+ from collections.abc import AsyncGenerator
7
9
  from contextlib import asynccontextmanager
8
10
  from datetime import UTC, datetime
9
- from pathlib import Path
10
11
  from urllib.parse import urlencode
11
12
 
12
13
  import httpx
@@ -16,9 +17,12 @@ from litestar import Controller, Request, get, post
16
17
  from litestar.exceptions import HTTPException
17
18
  from litestar.params import Parameter
18
19
  from litestar.response import Redirect, Template as TemplateResponse
19
- from sqlalchemy import func, select
20
+ from litestar.response.sse import ServerSentEvent
21
+ from sqlalchemy import select
20
22
  from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
23
+ from sqlalchemy.orm import selectinload
21
24
 
25
+ from skrift.db.models.oauth_account import OAuthAccount
22
26
  from skrift.db.models.role import Role, user_roles
23
27
  from skrift.db.models.user import User
24
28
  from skrift.db.services import setting_service
@@ -32,7 +36,15 @@ from skrift.setup.config_writer import (
32
36
  update_database_config,
33
37
  )
34
38
  from skrift.setup.providers import get_all_providers, get_provider_info
35
- from skrift.setup.state import can_connect_to_database, app_yaml_exists, get_database_url_from_yaml
39
+ from skrift.setup.state import (
40
+ can_connect_to_database,
41
+ get_database_url_from_yaml,
42
+ get_first_incomplete_step,
43
+ is_auth_configured,
44
+ is_site_configured,
45
+ run_migrations_if_needed,
46
+ reset_migrations_flag,
47
+ )
36
48
 
37
49
 
38
50
  @asynccontextmanager
@@ -75,27 +87,32 @@ class SetupController(Controller):
75
87
 
76
88
  @get("/")
77
89
  async def index(self, request: Request) -> Redirect:
78
- """Redirect to appropriate setup step."""
79
- # Check wizard progress from session
80
- wizard_step = request.session.get("setup_wizard_step", "database")
81
-
82
- # If we don't have app.yaml or db isn't configured, start at database
83
- if not app_yaml_exists():
84
- return Redirect(path="/setup/database")
90
+ """Redirect to the first incomplete setup step.
85
91
 
92
+ Uses smart detection to skip already-configured steps, allowing users
93
+ to resume setup without re-entering existing configuration.
94
+ """
95
+ # Check if database is configured and connectable
86
96
  can_connect, _ = await can_connect_to_database()
87
- if not can_connect:
88
- return Redirect(path="/setup/database")
97
+ if can_connect:
98
+ # Database is configured - go through configuring page to run migrations
99
+ return Redirect(path="/setup/configuring")
89
100
 
90
- # Otherwise go to the saved step
91
- return Redirect(path=f"/setup/{wizard_step}")
101
+ # Database not configured - go to database step
102
+ request.session["setup_wizard_step"] = "database"
103
+ return Redirect(path="/setup/database")
92
104
 
93
105
  @get("/database")
94
- async def database_step(self, request: Request) -> TemplateResponse:
106
+ async def database_step(self, request: Request) -> TemplateResponse | Redirect:
95
107
  """Step 1: Database configuration."""
96
108
  flash = request.session.pop("flash", None)
97
109
  error = request.session.pop("setup_error", None)
98
110
 
111
+ # If database is already configured and no errors, go to configuring page
112
+ can_connect, _ = await can_connect_to_database()
113
+ if can_connect and not error:
114
+ return Redirect(path="/setup/configuring")
115
+
99
116
  # Load current config if exists
100
117
  config = load_config()
101
118
  db_config = config.get("db", {})
@@ -167,41 +184,9 @@ class SetupController(Controller):
167
184
  request.session["setup_error"] = f"Connection failed: {error}"
168
185
  return Redirect(path="/setup/database")
169
186
 
170
- # Run migrations
171
- try:
172
- result = subprocess.run(
173
- ["skrift-db", "upgrade", "head"],
174
- capture_output=True,
175
- text=True,
176
- cwd=Path.cwd(),
177
- timeout=60,
178
- )
179
- if result.returncode != 0:
180
- request.session["setup_error"] = f"Migration failed: {result.stderr}"
181
- return Redirect(path="/setup/database")
182
- except subprocess.TimeoutExpired:
183
- request.session["setup_error"] = "Migration timed out"
184
- return Redirect(path="/setup/database")
185
- except FileNotFoundError:
186
- # skrift-db might not be installed yet, try alembic directly
187
- try:
188
- result = subprocess.run(
189
- ["alembic", "upgrade", "head"],
190
- capture_output=True,
191
- text=True,
192
- cwd=Path.cwd(),
193
- timeout=60,
194
- )
195
- if result.returncode != 0:
196
- request.session["setup_error"] = f"Migration failed: {result.stderr}"
197
- return Redirect(path="/setup/database")
198
- except Exception as e:
199
- request.session["setup_error"] = f"Could not run migrations: {e}"
200
- return Redirect(path="/setup/database")
201
-
202
- request.session["setup_wizard_step"] = "auth"
203
- request.session["flash"] = "Database configured successfully!"
204
- return Redirect(path="/setup/auth")
187
+ # Connection successful - redirect to configuring page to run migrations
188
+ request.session["setup_wizard_step"] = "configuring"
189
+ return Redirect(path="/setup/configuring")
205
190
 
206
191
  except Exception as e:
207
192
  request.session["setup_error"] = str(e)
@@ -213,12 +198,116 @@ class SetupController(Controller):
213
198
  request.session["setup_wizard_step"] = "auth"
214
199
  return Redirect(path="/setup/auth")
215
200
 
201
+ @get("/configuring")
202
+ async def configuring_step(self, request: Request) -> TemplateResponse | Redirect:
203
+ """Database configuration in progress page.
204
+
205
+ Shows a loading spinner while migrations run via SSE.
206
+ """
207
+ flash = request.session.pop("flash", None)
208
+ error = request.session.pop("setup_error", None)
209
+
210
+ # Verify we can connect to the database first
211
+ can_connect, connection_error = await can_connect_to_database()
212
+ if not can_connect:
213
+ request.session["setup_error"] = f"Cannot connect to database: {connection_error}"
214
+ return Redirect(path="/setup/database")
215
+
216
+ # Reset migrations flag so they run fresh via SSE
217
+ reset_migrations_flag()
218
+
219
+ return TemplateResponse(
220
+ "setup/configuring.html",
221
+ context={
222
+ "flash": flash,
223
+ "error": error,
224
+ "step": 1,
225
+ "total_steps": 4,
226
+ },
227
+ )
228
+
229
+ @get("/configuring/status")
230
+ async def configuring_status(self, request: Request) -> ServerSentEvent:
231
+ """SSE endpoint for database configuration status.
232
+
233
+ Streams migration progress and completion status.
234
+ """
235
+ async def generate_status() -> AsyncGenerator[str, None]:
236
+ # Send initial status
237
+ yield json.dumps({
238
+ "status": "running",
239
+ "message": "Testing database connection...",
240
+ "detail": "",
241
+ })
242
+
243
+ await asyncio.sleep(0.5)
244
+
245
+ # Test connection
246
+ can_connect, connection_error = await can_connect_to_database()
247
+ if not can_connect:
248
+ yield json.dumps({
249
+ "status": "error",
250
+ "message": f"Database connection failed: {connection_error}",
251
+ })
252
+ return
253
+
254
+ yield json.dumps({
255
+ "status": "running",
256
+ "message": "Running database migrations...",
257
+ "detail": "This may take a moment",
258
+ })
259
+
260
+ await asyncio.sleep(0.3)
261
+
262
+ # Run migrations
263
+ success, error = run_migrations_if_needed()
264
+
265
+ if not success:
266
+ yield json.dumps({
267
+ "status": "error",
268
+ "message": f"Migration failed: {error}",
269
+ })
270
+ return
271
+
272
+ yield json.dumps({
273
+ "status": "running",
274
+ "message": "Verifying database schema...",
275
+ "detail": "",
276
+ })
277
+
278
+ await asyncio.sleep(0.3)
279
+
280
+ # Determine next step
281
+ if is_auth_configured():
282
+ if await is_site_configured():
283
+ next_step = "admin"
284
+ else:
285
+ next_step = "site"
286
+ else:
287
+ next_step = "auth"
288
+
289
+ # All done - include next step
290
+ yield json.dumps({
291
+ "status": "complete",
292
+ "message": "Database configured successfully!",
293
+ "next_step": next_step,
294
+ })
295
+
296
+ return ServerSentEvent(generate_status())
297
+
216
298
  @get("/auth")
217
- async def auth_step(self, request: Request) -> TemplateResponse:
299
+ async def auth_step(self, request: Request) -> TemplateResponse | Redirect:
218
300
  """Step 2: Authentication providers."""
219
301
  flash = request.session.pop("flash", None)
220
302
  error = request.session.pop("setup_error", None)
221
303
 
304
+ # If auth is already configured and no errors, skip to next step
305
+ if is_auth_configured() and not error:
306
+ next_step = await get_first_incomplete_step()
307
+ if next_step.value != "auth":
308
+ request.session["setup_wizard_step"] = next_step.value
309
+ return Redirect(path=f"/setup/{next_step.value}")
310
+
222
311
  # Get current redirect URL from request
223
312
  scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
224
313
  host = request.headers.get("host", request.url.netloc)
@@ -291,20 +380,29 @@ class SetupController(Controller):
291
380
  use_env_vars=use_env_vars,
292
381
  )
293
382
 
294
- request.session["setup_wizard_step"] = "site"
383
+ # Determine next step using smart detection
384
+ next_step = await get_first_incomplete_step()
385
+ request.session["setup_wizard_step"] = next_step.value
295
386
  request.session["flash"] = "Authentication configured successfully!"
296
- return Redirect(path="/setup/site")
387
+ return Redirect(path=f"/setup/{next_step.value}")
297
388
 
298
389
  except Exception as e:
299
390
  request.session["setup_error"] = str(e)
300
391
  return Redirect(path="/setup/auth")
301
392
 
302
393
  @get("/site")
303
- async def site_step(self, request: Request) -> TemplateResponse:
394
+ async def site_step(self, request: Request) -> TemplateResponse | Redirect:
304
395
  """Step 3: Site settings."""
305
396
  flash = request.session.pop("flash", None)
306
397
  error = request.session.pop("setup_error", None)
307
398
 
399
+ # If site is already configured and no errors, skip to next step
400
+ if await is_site_configured() and not error:
401
+ next_step = await get_first_incomplete_step()
402
+ if next_step.value != "site":
403
+ request.session["setup_wizard_step"] = next_step.value
404
+ return Redirect(path=f"/setup/{next_step.value}")
405
+
308
406
  return TemplateResponse(
309
407
  "setup/site.html",
310
408
  context={
@@ -356,9 +454,11 @@ class SetupController(Controller):
356
454
  # Reload cache
357
455
  await setting_service.load_site_settings_cache(db_session)
358
456
 
359
- request.session["setup_wizard_step"] = "admin"
457
+ # Determine next step using smart detection - should be admin at this point
458
+ next_step = await get_first_incomplete_step()
459
+ request.session["setup_wizard_step"] = next_step.value
360
460
  request.session["flash"] = "Site settings saved!"
361
- return Redirect(path="/setup/admin")
461
+ return Redirect(path=f"/setup/{next_step.value}")
362
462
 
363
463
  except Exception as e:
364
464
  request.session["setup_error"] = str(e)
@@ -621,31 +721,68 @@ class SetupAuthController(Controller):
621
721
  if not oauth_id:
622
722
  raise HTTPException(status_code=400, detail="Could not determine user ID")
623
723
 
724
+ email = user_data["email"]
725
+
624
726
  # Create user and mark setup complete
625
727
  async with get_setup_db_session() as db_session:
626
- # Check if user exists
728
+ # Step 1: Check if OAuth account already exists
627
729
  result = await db_session.execute(
628
- select(User).where(User.oauth_id == oauth_id, User.oauth_provider == provider)
730
+ select(OAuthAccount)
731
+ .options(selectinload(OAuthAccount.user))
732
+ .where(OAuthAccount.provider == provider, OAuthAccount.provider_account_id == oauth_id)
629
733
  )
630
- user = result.scalar_one_or_none()
734
+ oauth_account = result.scalar_one_or_none()
631
735
 
632
- if user:
736
+ if oauth_account:
737
+ # Existing OAuth account - update user profile
738
+ user = oauth_account.user
633
739
  user.name = user_data["name"]
634
740
  if user_data["picture_url"]:
635
741
  user.picture_url = user_data["picture_url"]
636
742
  user.last_login_at = datetime.now(UTC)
743
+ if email:
744
+ oauth_account.provider_email = email
637
745
  else:
638
- # Create new user
639
- user = User(
640
- oauth_provider=provider,
641
- oauth_id=oauth_id,
642
- email=user_data["email"],
643
- name=user_data["name"],
644
- picture_url=user_data["picture_url"],
645
- last_login_at=datetime.now(UTC),
646
- )
647
- db_session.add(user)
648
- await db_session.flush()
746
+ # Step 2: Check if a user with this email already exists
747
+ user = None
748
+ if email:
749
+ result = await db_session.execute(
750
+ select(User).where(User.email == email)
751
+ )
752
+ user = result.scalar_one_or_none()
753
+
754
+ if user:
755
+ # Link new OAuth account to existing user
756
+ oauth_account = OAuthAccount(
757
+ provider=provider,
758
+ provider_account_id=oauth_id,
759
+ provider_email=email,
760
+ user_id=user.id,
761
+ )
762
+ db_session.add(oauth_account)
763
+ # Update user profile
764
+ user.name = user_data["name"]
765
+ if user_data["picture_url"]:
766
+ user.picture_url = user_data["picture_url"]
767
+ user.last_login_at = datetime.now(UTC)
768
+ else:
769
+ # Step 3: Create new user + OAuth account
770
+ user = User(
771
+ email=email,
772
+ name=user_data["name"],
773
+ picture_url=user_data["picture_url"],
774
+ last_login_at=datetime.now(UTC),
775
+ )
776
+ db_session.add(user)
777
+ await db_session.flush()
778
+
779
+ oauth_account = OAuthAccount(
780
+ provider=provider,
781
+ provider_account_id=oauth_id,
782
+ provider_email=email,
783
+ user_id=user.id,
784
+ )
785
+ db_session.add(oauth_account)
649
786
 
650
787
  # Ensure roles are synced (they may not exist if DB was created after server start)
651
788
  from skrift.auth import sync_roles_to_database
skrift/setup/providers.py CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  from dataclasses import dataclass
4
4
 
5
+ DUMMY_PROVIDER_KEY = "dummy"
6
+
5
7
 
6
8
  @dataclass
7
9
  class OAuthProviderInfo:
@@ -150,6 +152,17 @@ OAUTH_PROVIDERS = {
150
152
  """.strip(),
151
153
  icon="twitter",
152
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
+ ),
153
166
  }
154
167
 
155
168
 
@@ -159,5 +172,43 @@ def get_provider_info(provider: str) -> OAuthProviderInfo | None:
159
172
 
160
173
 
161
174
  def get_all_providers() -> dict[str, OAuthProviderInfo]:
162
- """Get all available OAuth providers."""
163
- return OAUTH_PROVIDERS.copy()
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)