skrift 0.1.0a1__tar.gz → 0.1.0a2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. {skrift-0.1.0a1 → skrift-0.1.0a2}/.gitignore +1 -0
  2. {skrift-0.1.0a1 → skrift-0.1.0a2}/PKG-INFO +3 -1
  3. {skrift-0.1.0a1 → skrift-0.1.0a2}/pyproject.toml +9 -1
  4. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/asgi.py +19 -11
  5. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/config.py +59 -5
  6. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/controllers/auth.py +84 -6
  7. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/setup/config_writer.py +4 -2
  8. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/setup/providers.py +53 -2
  9. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/setup/state.py +3 -2
  10. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/static/css/style.css +3 -3
  11. skrift-0.1.0a2/skrift/templates/auth/dummy_login.html +102 -0
  12. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/templates/auth/login.html +14 -0
  13. {skrift-0.1.0a1 → skrift-0.1.0a2}/README.md +0 -0
  14. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/__init__.py +0 -0
  15. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/__main__.py +0 -0
  16. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/admin/__init__.py +0 -0
  17. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/admin/controller.py +0 -0
  18. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/admin/navigation.py +0 -0
  19. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/alembic/env.py +0 -0
  20. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/alembic/script.py.mako +0 -0
  21. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py +0 -0
  22. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py +0 -0
  23. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py +0 -0
  24. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py +0 -0
  25. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/alembic/versions/20260122_200000_add_settings_table.py +0 -0
  26. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/alembic.ini +0 -0
  27. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/auth/__init__.py +0 -0
  28. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/auth/guards.py +0 -0
  29. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/auth/roles.py +0 -0
  30. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/auth/services.py +0 -0
  31. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/cli.py +0 -0
  32. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/controllers/__init__.py +0 -0
  33. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/controllers/web.py +0 -0
  34. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/db/__init__.py +0 -0
  35. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/db/base.py +0 -0
  36. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/db/models/__init__.py +0 -0
  37. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/db/models/page.py +0 -0
  38. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/db/models/role.py +0 -0
  39. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/db/models/setting.py +0 -0
  40. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/db/models/user.py +0 -0
  41. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/db/services/__init__.py +0 -0
  42. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/db/services/page_service.py +0 -0
  43. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/db/services/setting_service.py +0 -0
  44. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/lib/__init__.py +0 -0
  45. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/lib/exceptions.py +0 -0
  46. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/lib/template.py +0 -0
  47. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/setup/__init__.py +0 -0
  48. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/setup/controller.py +0 -0
  49. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/setup/middleware.py +0 -0
  50. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/templates/admin/admin.html +0 -0
  51. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/templates/admin/base.html +0 -0
  52. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/templates/admin/pages/edit.html +0 -0
  53. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/templates/admin/pages/list.html +0 -0
  54. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/templates/admin/settings/site.html +0 -0
  55. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/templates/admin/users/list.html +0 -0
  56. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/templates/admin/users/roles.html +0 -0
  57. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/templates/base.html +0 -0
  58. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/templates/error-404.html +0 -0
  59. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/templates/error-500.html +0 -0
  60. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/templates/error.html +0 -0
  61. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/templates/index.html +0 -0
  62. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/templates/page.html +0 -0
  63. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/templates/setup/admin.html +0 -0
  64. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/templates/setup/auth.html +0 -0
  65. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/templates/setup/base.html +0 -0
  66. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/templates/setup/complete.html +0 -0
  67. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/templates/setup/database.html +0 -0
  68. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/templates/setup/restart.html +0 -0
  69. {skrift-0.1.0a1 → skrift-0.1.0a2}/skrift/templates/setup/site.html +0 -0
@@ -22,3 +22,4 @@ wheels/
22
22
  app.yaml
23
23
  app.bak.yaml
24
24
  app.yaml.backup.*
25
+ site/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skrift
3
- Version: 0.1.0a1
3
+ Version: 0.1.0a2
4
4
  Summary: A lightweight async Python CMS for crafting modern websites
5
5
  Requires-Python: >=3.13
6
6
  Requires-Dist: advanced-alchemy>=0.26.0
@@ -15,6 +15,8 @@ Requires-Dist: pyyaml>=6.0.0
15
15
  Requires-Dist: ruamel-yaml>=0.18.0
16
16
  Requires-Dist: sqlalchemy[asyncio]>=2.0.36
17
17
  Requires-Dist: uvicorn>=0.34.0
18
+ Provides-Extra: docs
19
+ Requires-Dist: zensical>=0.0.19; extra == 'docs'
18
20
  Description-Content-Type: text/markdown
19
21
 
20
22
  # Skrift
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "skrift"
3
- version = "0.1.0a1"
3
+ version = "0.1.0a2"
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,11 @@ package = true
30
30
  requires = ["hatchling"]
31
31
  build-backend = "hatchling.build"
32
32
 
33
+ [dependency-groups]
34
+ docs = [
35
+ "zensical>=0.0.19",
36
+ ]
37
+
33
38
  [tool.hatch.build]
34
39
  include = [
35
40
  "skrift/**/*.py",
@@ -45,6 +50,9 @@ exclude = [
45
50
  "*.db",
46
51
  ]
47
52
 
53
+ [project.optional-dependencies]
54
+ docs = ["zensical>=0.0.19"]
55
+
48
56
  [tool.ruff]
49
57
  line-length = 100
50
58
  target-version = "py313"
@@ -29,7 +29,7 @@ from litestar.static_files import create_static_files_router
29
29
  from litestar.template import TemplateConfig
30
30
  from litestar.types import ASGIApp, Receive, Scope, Send
31
31
 
32
- from skrift.config import get_settings, is_config_valid
32
+ from skrift.config import get_config_path, get_settings, is_config_valid
33
33
  from skrift.db.base import Base
34
34
  from skrift.db.services.setting_service import (
35
35
  load_site_settings_cache,
@@ -45,7 +45,7 @@ from skrift.lib.exceptions import http_exception_handler, internal_server_error_
45
45
 
46
46
  def load_controllers() -> list:
47
47
  """Load controllers from app.yaml configuration."""
48
- config_path = Path.cwd() / "app.yaml"
48
+ config_path = get_config_path()
49
49
 
50
50
  if not config_path.exists():
51
51
  return []
@@ -196,12 +196,7 @@ class AppDispatcher:
196
196
  await self.setup_app(scope, receive, send)
197
197
  return
198
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
199
+ # Check if setup is complete in DB
205
200
  if await self._is_setup_complete_in_db():
206
201
  # Setup complete - try to get/create main app
207
202
  main_app = await self._get_or_create_main_app()
@@ -215,8 +210,13 @@ class AppDispatcher:
215
210
  f"Setup complete but cannot start application: {self._main_app_error}"
216
211
  )
217
212
  else:
218
- # Setup not complete - redirect to /setup
219
- await self._redirect(send, "/setup")
213
+ # Setup not complete
214
+ # Route /auth/* to setup app for OAuth callbacks during setup
215
+ if path.startswith("/auth"):
216
+ await self.setup_app(scope, receive, send)
217
+ else:
218
+ # Redirect other paths to /setup
219
+ await self._redirect(send, "/setup")
220
220
 
221
221
  async def _is_setup_complete_in_db(self) -> bool:
222
222
  """Check if setup is complete in the database."""
@@ -270,6 +270,10 @@ def create_app() -> Litestar:
270
270
  This app has all routes for normal operation. It is used by the dispatcher
271
271
  after setup is complete.
272
272
  """
273
+ # CRITICAL: Check for dummy auth in production BEFORE anything else
274
+ from skrift.setup.providers import validate_no_dummy_auth_in_production
275
+ validate_no_dummy_auth_in_production()
276
+
273
277
  settings = get_settings()
274
278
 
275
279
  # Load controllers from app.yaml
@@ -404,7 +408,7 @@ def create_setup_app() -> Litestar:
404
408
 
405
409
  # Also try to get the raw db URL from config (before env var resolution)
406
410
  if not db_url:
407
- config_path = Path.cwd() / "app.yaml"
411
+ config_path = get_config_path()
408
412
  if config_path.exists():
409
413
  try:
410
414
  with open(config_path, "r") as f:
@@ -493,6 +497,10 @@ def create_dispatcher() -> ASGIApp:
493
497
  This is the main entry point. The dispatcher handles routing between
494
498
  setup and main apps, with lazy creation of the main app after setup completes.
495
499
  """
500
+ # CRITICAL: Check for dummy auth in production BEFORE anything else
501
+ from skrift.setup.providers import validate_no_dummy_auth_in_production
502
+ validate_no_dummy_auth_in_production()
503
+
496
504
  global _dispatcher
497
505
  from skrift.setup.state import get_database_url_from_yaml
498
506
 
@@ -16,6 +16,31 @@ load_dotenv(_env_file)
16
16
  # Pattern to match $VAR_NAME environment variable references
17
17
  ENV_VAR_PATTERN = re.compile(r"\$([A-Z_][A-Z0-9_]*)")
18
18
 
19
+ # Environment configuration
20
+ SKRIFT_ENV = "SKRIFT_ENV"
21
+ DEFAULT_ENVIRONMENT = "production"
22
+
23
+
24
+ def get_environment() -> str:
25
+ """Get the current environment name, normalized to lowercase.
26
+
27
+ Reads from SKRIFT_ENV environment variable. Defaults to "production".
28
+ """
29
+ env = os.environ.get(SKRIFT_ENV, DEFAULT_ENVIRONMENT)
30
+ return env.lower().strip()
31
+
32
+
33
+ def get_config_path() -> Path:
34
+ """Get the path to the environment-specific config file.
35
+
36
+ Production -> app.yaml
37
+ Other envs -> app.{env}.yaml (e.g., app.dev.yaml)
38
+ """
39
+ env = get_environment()
40
+ if env == "production":
41
+ return Path.cwd() / "app.yaml"
42
+ return Path.cwd() / f"app.{env}.yaml"
43
+
19
44
 
20
45
  def interpolate_env_vars(value, strict: bool = True):
21
46
  """Recursively replace $VAR_NAME with os.environ values.
@@ -54,10 +79,10 @@ def load_app_config(interpolate: bool = True, strict: bool = True) -> dict:
54
79
  Returns:
55
80
  Parsed configuration dictionary
56
81
  """
57
- config_path = Path.cwd() / "app.yaml"
82
+ config_path = get_config_path()
58
83
 
59
84
  if not config_path.exists():
60
- raise FileNotFoundError(f"app.yaml not found at {config_path}")
85
+ raise FileNotFoundError(f"{config_path.name} not found at {config_path}")
61
86
 
62
87
  with open(config_path, "r") as f:
63
88
  config = yaml.safe_load(f)
@@ -69,7 +94,7 @@ def load_app_config(interpolate: bool = True, strict: bool = True) -> dict:
69
94
 
70
95
  def load_raw_app_config() -> dict | None:
71
96
  """Load app.yaml without any processing. Returns None if file doesn't exist."""
72
- config_path = Path.cwd() / "app.yaml"
97
+ config_path = get_config_path()
73
98
 
74
99
  if not config_path.exists():
75
100
  return None
@@ -98,11 +123,40 @@ class OAuthProviderConfig(BaseModel):
98
123
  tenant_id: str | None = None
99
124
 
100
125
 
126
+ class DummyProviderConfig(BaseModel):
127
+ """Dummy provider configuration (no credentials required)."""
128
+
129
+ pass
130
+
131
+
132
+ # Union type for provider configs - dummy has no required fields
133
+ ProviderConfig = OAuthProviderConfig | DummyProviderConfig
134
+
135
+
101
136
  class AuthConfig(BaseModel):
102
137
  """Authentication configuration."""
103
138
 
104
139
  redirect_base_url: str = "http://localhost:8000"
105
- providers: dict[str, OAuthProviderConfig] = {}
140
+ providers: dict[str, ProviderConfig] = {}
141
+
142
+ @classmethod
143
+ def _parse_provider(cls, name: str, config: dict) -> ProviderConfig:
144
+ """Parse a provider config, using the appropriate model based on provider name."""
145
+ if name == "dummy":
146
+ return DummyProviderConfig(**config)
147
+ return OAuthProviderConfig(**config)
148
+
149
+ def __init__(self, **data):
150
+ # Convert raw provider dicts to appropriate config objects
151
+ if "providers" in data and isinstance(data["providers"], dict):
152
+ parsed_providers = {}
153
+ for name, config in data["providers"].items():
154
+ if isinstance(config, dict):
155
+ parsed_providers[name] = self._parse_provider(name, config)
156
+ else:
157
+ parsed_providers[name] = config
158
+ data["providers"] = parsed_providers
159
+ super().__init__(**data)
106
160
 
107
161
  def get_redirect_uri(self, provider: str) -> str:
108
162
  """Get the OAuth callback URL for a provider."""
@@ -137,7 +191,7 @@ def is_config_valid() -> tuple[bool, str | None]:
137
191
  try:
138
192
  config = load_raw_app_config()
139
193
  if config is None:
140
- return False, "app.yaml not found"
194
+ return False, f"{get_config_path().name} not found"
141
195
 
142
196
  # Check database URL
143
197
  db_config = config.get("db", {})
@@ -1,6 +1,7 @@
1
1
  """Authentication controller for OAuth login flows.
2
2
 
3
3
  Supports multiple OAuth providers: Google, GitHub, Microsoft, Discord, Facebook, X (Twitter).
4
+ Also supports a development-only "dummy" provider for testing.
4
5
  """
5
6
 
6
7
  import base64
@@ -11,7 +12,7 @@ from typing import Annotated
11
12
  from urllib.parse import urlencode
12
13
 
13
14
  import httpx
14
- from litestar import Controller, Request, get
15
+ from litestar import Controller, Request, get, post
15
16
  from litestar.exceptions import HTTPException, NotFoundException
16
17
  from litestar.params import Parameter
17
18
  from litestar.response import Redirect, Template as TemplateResponse
@@ -20,7 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
20
21
 
21
22
  from skrift.config import get_settings
22
23
  from skrift.db.models.user import User
23
- from skrift.setup.providers import OAUTH_PROVIDERS, get_provider_info
24
+ from skrift.setup.providers import DUMMY_PROVIDER_KEY, OAUTH_PROVIDERS, get_provider_info
24
25
 
25
26
 
26
27
  def get_auth_url(provider: str, settings, state: str, code_challenge: str | None = None) -> str:
@@ -227,8 +228,8 @@ class AuthController(Controller):
227
228
  self,
228
229
  request: Request,
229
230
  provider: str,
230
- ) -> Redirect:
231
- """Redirect to OAuth provider consent screen."""
231
+ ) -> Redirect | TemplateResponse:
232
+ """Redirect to OAuth provider consent screen, or show dummy login form."""
232
233
  settings = get_settings()
233
234
  provider_info = get_provider_info(provider)
234
235
 
@@ -238,6 +239,14 @@ class AuthController(Controller):
238
239
  if provider not in settings.auth.providers:
239
240
  raise NotFoundException(f"Provider {provider} not configured")
240
241
 
242
+ # Dummy provider shows local login form instead of redirecting to OAuth
243
+ if provider == DUMMY_PROVIDER_KEY:
244
+ flash = request.session.pop("flash", None)
245
+ return TemplateResponse(
246
+ "auth/dummy_login.html",
247
+ context={"flash": flash},
248
+ )
249
+
241
250
  # Generate CSRF state token
242
251
  state = secrets.token_urlsafe(32)
243
252
  request.session["oauth_state"] = state
@@ -348,22 +357,91 @@ class AuthController(Controller):
348
357
  flash = request.session.pop("flash", None)
349
358
  settings = get_settings()
350
359
 
351
- # Get configured providers
360
+ # Get configured providers (excluding dummy from main list)
352
361
  configured_providers = list(settings.auth.providers.keys())
353
362
  providers = {
354
363
  key: OAUTH_PROVIDERS[key]
355
364
  for key in configured_providers
356
- if key in OAUTH_PROVIDERS
365
+ if key in OAUTH_PROVIDERS and key != DUMMY_PROVIDER_KEY
357
366
  }
358
367
 
368
+ # Check if dummy provider is configured
369
+ has_dummy = DUMMY_PROVIDER_KEY in settings.auth.providers
370
+
359
371
  return TemplateResponse(
360
372
  "auth/login.html",
361
373
  context={
362
374
  "flash": flash,
363
375
  "providers": providers,
376
+ "has_dummy": has_dummy,
364
377
  },
365
378
  )
366
379
 
380
+ @post("/dummy-login")
381
+ async def dummy_login_submit(
382
+ self,
383
+ request: Request,
384
+ db_session: AsyncSession,
385
+ ) -> Redirect:
386
+ """Process dummy login form submission."""
387
+ settings = get_settings()
388
+
389
+ if DUMMY_PROVIDER_KEY not in settings.auth.providers:
390
+ raise NotFoundException("Dummy provider not configured")
391
+
392
+ # Parse form data from request
393
+ form_data = await request.form()
394
+ email = form_data.get("email", "").strip()
395
+ name = form_data.get("name", "").strip()
396
+
397
+ if not email:
398
+ request.session["flash"] = "Email is required"
399
+ return Redirect(path="/auth/dummy/login")
400
+
401
+ # Default name to email username if not provided
402
+ if not name:
403
+ name = email.split("@")[0]
404
+
405
+ # Generate deterministic oauth_id from email
406
+ oauth_id = f"dummy_{hashlib.sha256(email.encode()).hexdigest()[:16]}"
407
+
408
+ # Find or create user
409
+ result = await db_session.execute(
410
+ select(User).where(
411
+ User.oauth_id == oauth_id,
412
+ User.oauth_provider == DUMMY_PROVIDER_KEY,
413
+ )
414
+ )
415
+ user = result.scalar_one_or_none()
416
+
417
+ if user:
418
+ # Update existing user
419
+ user.name = name
420
+ user.email = email
421
+ user.last_login_at = datetime.now(UTC)
422
+ else:
423
+ # Create new user
424
+ user = User(
425
+ oauth_provider=DUMMY_PROVIDER_KEY,
426
+ oauth_id=oauth_id,
427
+ email=email,
428
+ name=name,
429
+ last_login_at=datetime.now(UTC),
430
+ )
431
+ db_session.add(user)
432
+ await db_session.flush()
433
+
434
+ await db_session.commit()
435
+
436
+ # Set session with user info
437
+ request.session["user_id"] = str(user.id)
438
+ request.session["user_name"] = user.name
439
+ request.session["user_email"] = user.email
440
+ request.session["user_picture_url"] = user.picture_url
441
+ request.session["flash"] = "Successfully logged in!"
442
+
443
+ return Redirect(path="/")
444
+
367
445
  @get("/logout")
368
446
  async def logout(self, request: Request) -> Redirect:
369
447
  """Clear session and redirect to home."""
@@ -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:
@@ -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)
@@ -12,6 +12,7 @@ from pathlib import Path
12
12
  from sqlalchemy import text
13
13
  from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
14
14
 
15
+ from skrift.config import get_config_path
15
16
  from skrift.db.services.setting_service import SETUP_COMPLETED_AT_KEY, get_setting
16
17
 
17
18
 
@@ -27,7 +28,7 @@ class SetupStep(Enum):
27
28
 
28
29
  def app_yaml_exists() -> bool:
29
30
  """Check if app.yaml exists in the current working directory."""
30
- return (Path.cwd() / "app.yaml").exists()
31
+ return get_config_path().exists()
31
32
 
32
33
 
33
34
  def get_database_url_from_yaml() -> str | None:
@@ -38,7 +39,7 @@ def get_database_url_from_yaml() -> str | None:
38
39
  """
39
40
  import yaml
40
41
 
41
- config_path = Path.cwd() / "app.yaml"
42
+ config_path = get_config_path()
42
43
  if not config_path.exists():
43
44
  return None
44
45
 
@@ -175,7 +175,7 @@ a {
175
175
  position: relative;
176
176
  }
177
177
 
178
- a:not([role="button"])::after {
178
+ a:not([role="button"]):not(.oauth-btn)::after {
179
179
  content: '';
180
180
  position: absolute;
181
181
  left: 0;
@@ -188,11 +188,11 @@ a:not([role="button"])::after {
188
188
  transition: transform 500ms ease-out;
189
189
  }
190
190
 
191
- a:hover {
191
+ a:not([role="button"]):not(.oauth-btn):hover {
192
192
  color: var(--color-primary-hover);
193
193
  }
194
194
 
195
- a:not([role="button"]):hover::after {
195
+ a:not([role="button"]):not(.oauth-btn):hover::after {
196
196
  transform: scaleX(1);
197
197
  transform-origin: left;
198
198
  }
@@ -0,0 +1,102 @@
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Dummy Login (Dev) - {{ site_name() }}{% endblock %}
4
+
5
+ {% block head %}
6
+ <style>
7
+ .login-container {
8
+ max-width: 400px;
9
+ margin: 2rem auto;
10
+ }
11
+
12
+ .warning-banner {
13
+ background: #fef3c7;
14
+ border: 1px solid #f59e0b;
15
+ color: #92400e;
16
+ padding: 1rem;
17
+ border-radius: 0.5rem;
18
+ margin-bottom: 1.5rem;
19
+ text-align: center;
20
+ font-weight: 600;
21
+ }
22
+
23
+ .form-group {
24
+ margin-bottom: 1rem;
25
+ }
26
+
27
+ .form-group label {
28
+ display: block;
29
+ margin-bottom: 0.5rem;
30
+ font-weight: 600;
31
+ }
32
+
33
+ .form-group input {
34
+ width: 100%;
35
+ padding: 0.75rem;
36
+ border: 1px solid var(--color-border, #ccc);
37
+ border-radius: 0.5rem;
38
+ font-size: 1rem;
39
+ }
40
+
41
+ .form-group small {
42
+ display: block;
43
+ margin-top: 0.25rem;
44
+ color: var(--color-text-muted, #666);
45
+ }
46
+
47
+ .submit-btn {
48
+ width: 100%;
49
+ padding: 1rem;
50
+ background: #6b7280;
51
+ color: white;
52
+ border: none;
53
+ border-radius: 0.5rem;
54
+ font-size: 1rem;
55
+ font-weight: 600;
56
+ cursor: pointer;
57
+ margin-top: 1rem;
58
+ }
59
+
60
+ .submit-btn:hover {
61
+ background: #4b5563;
62
+ }
63
+
64
+ .back-link {
65
+ display: block;
66
+ text-align: center;
67
+ margin-top: 1.5rem;
68
+ color: var(--color-text-muted, #666);
69
+ }
70
+ </style>
71
+ {% endblock %}
72
+
73
+ {% block content %}
74
+ <div class="login-container">
75
+ <article>
76
+ <header>
77
+ <h1>Dummy Login</h1>
78
+ </header>
79
+
80
+ <div class="warning-banner">
81
+ DEVELOPMENT ONLY - Not for production use
82
+ </div>
83
+
84
+ <form method="post" action="/auth/dummy-login">
85
+ <div class="form-group">
86
+ <label for="email">Email</label>
87
+ <input type="email" id="email" name="email" required placeholder="test@example.com">
88
+ </div>
89
+
90
+ <div class="form-group">
91
+ <label for="name">Name</label>
92
+ <input type="text" id="name" name="name" placeholder="Test User">
93
+ <small>Optional. Defaults to email username if not provided.</small>
94
+ </div>
95
+
96
+ <button type="submit" class="submit-btn">Login</button>
97
+ </form>
98
+
99
+ <a href="/auth/login" class="back-link">&larr; Back to login options</a>
100
+ </article>
101
+ </div>
102
+ {% endblock %}
@@ -120,6 +120,20 @@
120
120
  <p>No authentication providers are configured. Please contact the site administrator.</p>
121
121
  {% endfor %}
122
122
  </div>
123
+
124
+ {% if has_dummy %}
125
+ <div style="margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--color-border, #ccc);">
126
+ <p style="text-align: center; color: var(--color-text-muted, #666); font-size: 0.875rem; margin-bottom: 1rem;">
127
+ Development Only
128
+ </p>
129
+ <a href="/auth/dummy/login" class="oauth-btn" style="background: #6b7280; color: white;">
130
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
131
+ <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"/>
132
+ </svg>
133
+ Dummy Login (Dev)
134
+ </a>
135
+ </div>
136
+ {% endif %}
123
137
  </article>
124
138
  </div>
125
139
  {% endblock %}
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