skrift 0.1.0a1__tar.gz → 0.1.0a3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {skrift-0.1.0a1 → skrift-0.1.0a3}/.gitignore +3 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/PKG-INFO +3 -1
- {skrift-0.1.0a1 → skrift-0.1.0a3}/pyproject.toml +9 -1
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/alembic/env.py +2 -1
- skrift-0.1.0a3/skrift/alembic/versions/20260129_add_oauth_accounts.py +134 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/alembic.ini +2 -2
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/asgi.py +19 -11
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/cli.py +22 -13
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/config.py +59 -5
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/controllers/auth.py +168 -22
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/db/models/__init__.py +2 -1
- skrift-0.1.0a3/skrift/db/models/oauth_account.py +37 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/db/models/user.py +5 -5
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/setup/config_writer.py +4 -2
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/setup/controller.py +209 -72
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/setup/providers.py +53 -2
- skrift-0.1.0a3/skrift/setup/state.py +315 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/static/css/style.css +3 -3
- skrift-0.1.0a3/skrift/templates/auth/dummy_login.html +102 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/templates/auth/login.html +14 -0
- skrift-0.1.0a3/skrift/templates/setup/configuring.html +158 -0
- skrift-0.1.0a1/skrift/setup/state.py +0 -134
- {skrift-0.1.0a1 → skrift-0.1.0a3}/README.md +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/__init__.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/__main__.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/admin/__init__.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/admin/controller.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/admin/navigation.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/alembic/script.py.mako +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/alembic/versions/20260122_200000_add_settings_table.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/auth/__init__.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/auth/guards.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/auth/roles.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/auth/services.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/controllers/__init__.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/controllers/web.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/db/__init__.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/db/base.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/db/models/page.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/db/models/role.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/db/models/setting.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/db/services/__init__.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/db/services/page_service.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/db/services/setting_service.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/lib/__init__.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/lib/exceptions.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/lib/template.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/setup/__init__.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/setup/middleware.py +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/templates/admin/admin.html +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/templates/admin/base.html +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/templates/admin/pages/edit.html +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/templates/admin/pages/list.html +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/templates/admin/settings/site.html +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/templates/admin/users/list.html +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/templates/admin/users/roles.html +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/templates/base.html +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/templates/error-404.html +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/templates/error-500.html +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/templates/error.html +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/templates/index.html +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/templates/page.html +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/templates/setup/admin.html +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/templates/setup/auth.html +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/templates/setup/base.html +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/templates/setup/complete.html +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/templates/setup/database.html +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/templates/setup/restart.html +0 -0
- {skrift-0.1.0a1 → skrift-0.1.0a3}/skrift/templates/setup/site.html +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: skrift
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.0a3
|
|
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.
|
|
3
|
+
version = "0.1.0a3"
|
|
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"
|
|
@@ -12,6 +12,7 @@ from skrift.config import get_settings
|
|
|
12
12
|
from skrift.db.base import Base
|
|
13
13
|
|
|
14
14
|
# Import all models to ensure they're registered with Base.metadata
|
|
15
|
+
from skrift.db.models.oauth_account import OAuthAccount # noqa: F401
|
|
15
16
|
from skrift.db.models.user import User # noqa: F401
|
|
16
17
|
from skrift.db.models.page import Page # noqa: F401
|
|
17
18
|
from skrift.db.models.role import Role, RolePermission # noqa: F401
|
|
@@ -31,7 +32,7 @@ def get_url() -> str:
|
|
|
31
32
|
"""Get database URL from settings or alembic.ini."""
|
|
32
33
|
try:
|
|
33
34
|
settings = get_settings()
|
|
34
|
-
return settings.
|
|
35
|
+
return settings.db.url
|
|
35
36
|
except Exception:
|
|
36
37
|
# Fall back to alembic.ini config if settings can't be loaded
|
|
37
38
|
return config.get_main_option("sqlalchemy.url", "")
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""add oauth_accounts table
|
|
2
|
+
|
|
3
|
+
Revision ID: 1a2b3c4d5e6f
|
|
4
|
+
Revises: 8f3a5c2d1e0b
|
|
5
|
+
Create Date: 2026-01-29 10:00:00.000000
|
|
6
|
+
|
|
7
|
+
This migration:
|
|
8
|
+
1. Creates the oauth_accounts table to store multiple OAuth identities per user
|
|
9
|
+
2. Migrates existing oauth data from users table to oauth_accounts
|
|
10
|
+
3. Makes users.email nullable (for providers like Twitter that don't provide email)
|
|
11
|
+
4. Removes oauth_provider and oauth_id columns from users table
|
|
12
|
+
"""
|
|
13
|
+
from typing import Sequence, Union
|
|
14
|
+
|
|
15
|
+
from alembic import op
|
|
16
|
+
import sqlalchemy as sa
|
|
17
|
+
from advanced_alchemy.types import GUID, DateTimeUTC
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# revision identifiers, used by Alembic.
|
|
21
|
+
revision: str = '1a2b3c4d5e6f'
|
|
22
|
+
down_revision: Union[str, None] = '8f3a5c2d1e0b'
|
|
23
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
24
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def upgrade() -> None:
|
|
28
|
+
# Step 1: Create oauth_accounts table
|
|
29
|
+
op.create_table(
|
|
30
|
+
'oauth_accounts',
|
|
31
|
+
sa.Column('id', GUID(length=16), nullable=False),
|
|
32
|
+
sa.Column('created_at', DateTimeUTC(timezone=True), nullable=False),
|
|
33
|
+
sa.Column('updated_at', DateTimeUTC(timezone=True), nullable=False),
|
|
34
|
+
sa.Column('sa_orm_sentinel', sa.Integer(), nullable=True),
|
|
35
|
+
sa.Column('provider', sa.String(length=50), nullable=False),
|
|
36
|
+
sa.Column('provider_account_id', sa.String(length=255), nullable=False),
|
|
37
|
+
sa.Column('provider_email', sa.String(length=255), nullable=True),
|
|
38
|
+
sa.Column('user_id', GUID(length=16), nullable=False),
|
|
39
|
+
sa.PrimaryKeyConstraint('id', name=op.f('pk_oauth_accounts')),
|
|
40
|
+
sa.ForeignKeyConstraint(
|
|
41
|
+
['user_id'], ['users.id'],
|
|
42
|
+
name=op.f('fk_oauth_accounts_user_id_users'),
|
|
43
|
+
ondelete='CASCADE'
|
|
44
|
+
),
|
|
45
|
+
sa.UniqueConstraint(
|
|
46
|
+
'provider', 'provider_account_id',
|
|
47
|
+
name='uq_oauth_provider_account'
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
op.create_index(
|
|
51
|
+
op.f('ix_oauth_accounts_user_id'),
|
|
52
|
+
'oauth_accounts',
|
|
53
|
+
['user_id'],
|
|
54
|
+
unique=False
|
|
55
|
+
)
|
|
56
|
+
op.create_index(
|
|
57
|
+
op.f('ix_oauth_accounts_provider_account'),
|
|
58
|
+
'oauth_accounts',
|
|
59
|
+
['provider', 'provider_account_id'],
|
|
60
|
+
unique=True
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Step 2: Migrate existing data from users to oauth_accounts
|
|
64
|
+
# Generate binary UUIDs (16 bytes) for new records and copy oauth data
|
|
65
|
+
conn = op.get_bind()
|
|
66
|
+
conn.execute(sa.text("""
|
|
67
|
+
INSERT INTO oauth_accounts (id, created_at, updated_at, provider, provider_account_id, provider_email, user_id)
|
|
68
|
+
SELECT
|
|
69
|
+
randomblob(16),
|
|
70
|
+
created_at,
|
|
71
|
+
updated_at,
|
|
72
|
+
oauth_provider,
|
|
73
|
+
oauth_id,
|
|
74
|
+
email,
|
|
75
|
+
id
|
|
76
|
+
FROM users
|
|
77
|
+
WHERE oauth_provider IS NOT NULL AND oauth_id IS NOT NULL
|
|
78
|
+
"""))
|
|
79
|
+
|
|
80
|
+
# Step 3: Make email nullable on users table
|
|
81
|
+
# SQLite doesn't support ALTER COLUMN, so we need to recreate the table
|
|
82
|
+
# For SQLite, we'll use batch_alter_table
|
|
83
|
+
with op.batch_alter_table('users', schema=None) as batch_op:
|
|
84
|
+
# Drop the unique constraint on oauth_id
|
|
85
|
+
batch_op.drop_constraint('uq_users_oauth_id', type_='unique')
|
|
86
|
+
# Drop the oauth columns
|
|
87
|
+
batch_op.drop_column('oauth_provider')
|
|
88
|
+
batch_op.drop_column('oauth_id')
|
|
89
|
+
# Make email nullable - this requires recreating the column in SQLite
|
|
90
|
+
batch_op.alter_column('email',
|
|
91
|
+
existing_type=sa.String(length=255),
|
|
92
|
+
nullable=True)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def downgrade() -> None:
|
|
96
|
+
# Step 1: Add back oauth columns to users table
|
|
97
|
+
with op.batch_alter_table('users', schema=None) as batch_op:
|
|
98
|
+
batch_op.add_column(sa.Column('oauth_provider', sa.String(length=50), nullable=True))
|
|
99
|
+
batch_op.add_column(sa.Column('oauth_id', sa.String(length=255), nullable=True))
|
|
100
|
+
batch_op.alter_column('email',
|
|
101
|
+
existing_type=sa.String(length=255),
|
|
102
|
+
nullable=False)
|
|
103
|
+
|
|
104
|
+
# Step 2: Migrate data back from oauth_accounts to users
|
|
105
|
+
# Only migrate the first oauth account per user
|
|
106
|
+
conn = op.get_bind()
|
|
107
|
+
conn.execute(sa.text("""
|
|
108
|
+
UPDATE users
|
|
109
|
+
SET oauth_provider = (
|
|
110
|
+
SELECT provider FROM oauth_accounts
|
|
111
|
+
WHERE oauth_accounts.user_id = users.id
|
|
112
|
+
LIMIT 1
|
|
113
|
+
),
|
|
114
|
+
oauth_id = (
|
|
115
|
+
SELECT provider_account_id FROM oauth_accounts
|
|
116
|
+
WHERE oauth_accounts.user_id = users.id
|
|
117
|
+
LIMIT 1
|
|
118
|
+
)
|
|
119
|
+
"""))
|
|
120
|
+
|
|
121
|
+
# Step 3: Make oauth columns non-nullable and add unique constraint
|
|
122
|
+
with op.batch_alter_table('users', schema=None) as batch_op:
|
|
123
|
+
batch_op.alter_column('oauth_provider',
|
|
124
|
+
existing_type=sa.String(length=50),
|
|
125
|
+
nullable=False)
|
|
126
|
+
batch_op.alter_column('oauth_id',
|
|
127
|
+
existing_type=sa.String(length=255),
|
|
128
|
+
nullable=False)
|
|
129
|
+
batch_op.create_unique_constraint('uq_users_oauth_id', ['oauth_id'])
|
|
130
|
+
|
|
131
|
+
# Step 4: Drop oauth_accounts table
|
|
132
|
+
op.drop_index(op.f('ix_oauth_accounts_provider_account'), table_name='oauth_accounts')
|
|
133
|
+
op.drop_index(op.f('ix_oauth_accounts_user_id'), table_name='oauth_accounts')
|
|
134
|
+
op.drop_table('oauth_accounts')
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Alembic Configuration File
|
|
2
2
|
|
|
3
3
|
[alembic]
|
|
4
|
-
# Path to migration scripts
|
|
5
|
-
script_location = alembic
|
|
4
|
+
# Path to migration scripts (relative to this file)
|
|
5
|
+
script_location = %(here)s/alembic
|
|
6
6
|
|
|
7
7
|
# Template used to generate migration files
|
|
8
8
|
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(rev)s_%%(slug)s
|
|
@@ -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 =
|
|
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
|
-
#
|
|
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
|
|
219
|
-
|
|
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 =
|
|
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
|
|
|
@@ -19,24 +19,33 @@ def db() -> None:
|
|
|
19
19
|
"""
|
|
20
20
|
from alembic.config import main as alembic_main
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
import os
|
|
23
|
+
|
|
24
|
+
# Always run from the project root (where app.yaml and .env are)
|
|
25
|
+
# This ensures database paths like ./app.db resolve correctly
|
|
26
|
+
project_root = Path.cwd()
|
|
27
|
+
if not (project_root / "app.yaml").exists():
|
|
28
|
+
# If not in project root, try parent directory
|
|
29
|
+
project_root = Path(__file__).parent.parent
|
|
30
|
+
os.chdir(project_root)
|
|
31
|
+
|
|
32
|
+
# Find alembic.ini - check project root first, then skrift package directory
|
|
33
|
+
alembic_ini = project_root / "alembic.ini"
|
|
26
34
|
if not alembic_ini.exists():
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if alembic_ini.exists():
|
|
32
|
-
# Change to the directory containing alembic.ini
|
|
33
|
-
import os
|
|
34
|
-
os.chdir(module_dir)
|
|
35
|
-
else:
|
|
35
|
+
skrift_dir = Path(__file__).parent
|
|
36
|
+
alembic_ini = skrift_dir / "alembic.ini"
|
|
37
|
+
|
|
38
|
+
if not alembic_ini.exists():
|
|
36
39
|
print("Error: Could not find alembic.ini", file=sys.stderr)
|
|
37
40
|
print("Make sure you're running from the project root directory.", file=sys.stderr)
|
|
38
41
|
sys.exit(1)
|
|
39
42
|
|
|
43
|
+
# Build argv with config path at the beginning (before any subcommand)
|
|
44
|
+
# Original argv: ['skrift-db', 'upgrade', 'head']
|
|
45
|
+
# New argv: ['skrift-db', '-c', '/path/to/alembic.ini', 'upgrade', 'head']
|
|
46
|
+
new_argv = [sys.argv[0], "-c", str(alembic_ini)] + sys.argv[1:]
|
|
47
|
+
sys.argv = new_argv
|
|
48
|
+
|
|
40
49
|
# Pass through all CLI arguments to Alembic
|
|
41
50
|
sys.exit(alembic_main(sys.argv[1:]))
|
|
42
51
|
|
|
@@ -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 =
|
|
82
|
+
config_path = get_config_path()
|
|
58
83
|
|
|
59
84
|
if not config_path.exists():
|
|
60
|
-
raise FileNotFoundError(f"
|
|
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 =
|
|
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,
|
|
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, "
|
|
194
|
+
return False, f"{get_config_path().name} not found"
|
|
141
195
|
|
|
142
196
|
# Check database URL
|
|
143
197
|
db_config = config.get("db", {})
|