skrift 0.1.0a2__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.
- skrift/alembic/env.py +2 -1
- skrift/alembic/versions/20260129_add_oauth_accounts.py +134 -0
- skrift/alembic.ini +2 -2
- skrift/cli.py +22 -13
- skrift/controllers/auth.py +100 -32
- skrift/db/models/__init__.py +2 -1
- skrift/db/models/oauth_account.py +37 -0
- skrift/db/models/user.py +5 -5
- skrift/setup/controller.py +209 -72
- skrift/setup/state.py +182 -2
- skrift/templates/setup/configuring.html +158 -0
- {skrift-0.1.0a2.dist-info → skrift-0.1.0a3.dist-info}/METADATA +1 -1
- {skrift-0.1.0a2.dist-info → skrift-0.1.0a3.dist-info}/RECORD +15 -12
- {skrift-0.1.0a2.dist-info → skrift-0.1.0a3.dist-info}/WHEEL +0 -0
- {skrift-0.1.0a2.dist-info → skrift-0.1.0a3.dist-info}/entry_points.txt +0 -0
skrift/alembic/env.py
CHANGED
|
@@ -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')
|
skrift/alembic.ini
CHANGED
|
@@ -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
|
skrift/cli.py
CHANGED
|
@@ -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
|
|
skrift/controllers/auth.py
CHANGED
|
@@ -18,8 +18,10 @@ from litestar.params import Parameter
|
|
|
18
18
|
from litestar.response import Redirect, Template as TemplateResponse
|
|
19
19
|
from sqlalchemy import select
|
|
20
20
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
21
|
+
from sqlalchemy.orm import selectinload
|
|
21
22
|
|
|
22
23
|
from skrift.config import get_settings
|
|
24
|
+
from skrift.db.models.oauth_account import OAuthAccount
|
|
23
25
|
from skrift.db.models.user import User
|
|
24
26
|
from skrift.setup.providers import DUMMY_PROVIDER_KEY, OAUTH_PROVIDERS, get_provider_info
|
|
25
27
|
|
|
@@ -315,30 +317,67 @@ class AuthController(Controller):
|
|
|
315
317
|
if not oauth_id:
|
|
316
318
|
raise HTTPException(status_code=400, detail="Could not determine user ID")
|
|
317
319
|
|
|
318
|
-
|
|
320
|
+
email = user_data["email"]
|
|
321
|
+
|
|
322
|
+
# Step 1: Check if OAuth account already exists
|
|
319
323
|
result = await db_session.execute(
|
|
320
|
-
select(
|
|
324
|
+
select(OAuthAccount)
|
|
325
|
+
.options(selectinload(OAuthAccount.user))
|
|
326
|
+
.where(OAuthAccount.provider == provider, OAuthAccount.provider_account_id == oauth_id)
|
|
321
327
|
)
|
|
322
|
-
|
|
328
|
+
oauth_account = result.scalar_one_or_none()
|
|
323
329
|
|
|
324
|
-
if
|
|
325
|
-
#
|
|
330
|
+
if oauth_account:
|
|
331
|
+
# Existing OAuth account - update user profile
|
|
332
|
+
user = oauth_account.user
|
|
326
333
|
user.name = user_data["name"]
|
|
327
334
|
if user_data["picture_url"]:
|
|
328
335
|
user.picture_url = user_data["picture_url"]
|
|
329
336
|
user.last_login_at = datetime.now(UTC)
|
|
337
|
+
# Update provider email if changed
|
|
338
|
+
if email:
|
|
339
|
+
oauth_account.provider_email = email
|
|
330
340
|
else:
|
|
331
|
-
#
|
|
332
|
-
user =
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
341
|
+
# Step 2: Check if a user with this email already exists
|
|
342
|
+
user = None
|
|
343
|
+
if email:
|
|
344
|
+
result = await db_session.execute(
|
|
345
|
+
select(User).where(User.email == email)
|
|
346
|
+
)
|
|
347
|
+
user = result.scalar_one_or_none()
|
|
348
|
+
|
|
349
|
+
if user:
|
|
350
|
+
# Link new OAuth account to existing user
|
|
351
|
+
oauth_account = OAuthAccount(
|
|
352
|
+
provider=provider,
|
|
353
|
+
provider_account_id=oauth_id,
|
|
354
|
+
provider_email=email,
|
|
355
|
+
user_id=user.id,
|
|
356
|
+
)
|
|
357
|
+
db_session.add(oauth_account)
|
|
358
|
+
# Update user profile
|
|
359
|
+
user.name = user_data["name"]
|
|
360
|
+
if user_data["picture_url"]:
|
|
361
|
+
user.picture_url = user_data["picture_url"]
|
|
362
|
+
user.last_login_at = datetime.now(UTC)
|
|
363
|
+
else:
|
|
364
|
+
# Step 3: Create new user + OAuth account
|
|
365
|
+
user = User(
|
|
366
|
+
email=email,
|
|
367
|
+
name=user_data["name"],
|
|
368
|
+
picture_url=user_data["picture_url"],
|
|
369
|
+
last_login_at=datetime.now(UTC),
|
|
370
|
+
)
|
|
371
|
+
db_session.add(user)
|
|
372
|
+
await db_session.flush()
|
|
373
|
+
|
|
374
|
+
oauth_account = OAuthAccount(
|
|
375
|
+
provider=provider,
|
|
376
|
+
provider_account_id=oauth_id,
|
|
377
|
+
provider_email=email,
|
|
378
|
+
user_id=user.id,
|
|
379
|
+
)
|
|
380
|
+
db_session.add(oauth_account)
|
|
342
381
|
|
|
343
382
|
await db_session.commit()
|
|
344
383
|
|
|
@@ -405,31 +444,60 @@ class AuthController(Controller):
|
|
|
405
444
|
# Generate deterministic oauth_id from email
|
|
406
445
|
oauth_id = f"dummy_{hashlib.sha256(email.encode()).hexdigest()[:16]}"
|
|
407
446
|
|
|
408
|
-
#
|
|
447
|
+
# Step 1: Check if OAuth account already exists
|
|
409
448
|
result = await db_session.execute(
|
|
410
|
-
select(
|
|
411
|
-
|
|
412
|
-
|
|
449
|
+
select(OAuthAccount)
|
|
450
|
+
.options(selectinload(OAuthAccount.user))
|
|
451
|
+
.where(
|
|
452
|
+
OAuthAccount.provider == DUMMY_PROVIDER_KEY,
|
|
453
|
+
OAuthAccount.provider_account_id == oauth_id,
|
|
413
454
|
)
|
|
414
455
|
)
|
|
415
|
-
|
|
456
|
+
oauth_account = result.scalar_one_or_none()
|
|
416
457
|
|
|
417
|
-
if
|
|
418
|
-
#
|
|
458
|
+
if oauth_account:
|
|
459
|
+
# Existing OAuth account - update user profile
|
|
460
|
+
user = oauth_account.user
|
|
419
461
|
user.name = name
|
|
420
462
|
user.email = email
|
|
421
463
|
user.last_login_at = datetime.now(UTC)
|
|
464
|
+
oauth_account.provider_email = email
|
|
422
465
|
else:
|
|
423
|
-
#
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
oauth_id=oauth_id,
|
|
427
|
-
email=email,
|
|
428
|
-
name=name,
|
|
429
|
-
last_login_at=datetime.now(UTC),
|
|
466
|
+
# Step 2: Check if a user with this email already exists
|
|
467
|
+
result = await db_session.execute(
|
|
468
|
+
select(User).where(User.email == email)
|
|
430
469
|
)
|
|
431
|
-
|
|
432
|
-
|
|
470
|
+
user = result.scalar_one_or_none()
|
|
471
|
+
|
|
472
|
+
if user:
|
|
473
|
+
# Link new OAuth account to existing user
|
|
474
|
+
oauth_account = OAuthAccount(
|
|
475
|
+
provider=DUMMY_PROVIDER_KEY,
|
|
476
|
+
provider_account_id=oauth_id,
|
|
477
|
+
provider_email=email,
|
|
478
|
+
user_id=user.id,
|
|
479
|
+
)
|
|
480
|
+
db_session.add(oauth_account)
|
|
481
|
+
# Update user profile
|
|
482
|
+
user.name = name
|
|
483
|
+
user.last_login_at = datetime.now(UTC)
|
|
484
|
+
else:
|
|
485
|
+
# Step 3: Create new user + OAuth account
|
|
486
|
+
user = User(
|
|
487
|
+
email=email,
|
|
488
|
+
name=name,
|
|
489
|
+
last_login_at=datetime.now(UTC),
|
|
490
|
+
)
|
|
491
|
+
db_session.add(user)
|
|
492
|
+
await db_session.flush()
|
|
493
|
+
|
|
494
|
+
oauth_account = OAuthAccount(
|
|
495
|
+
provider=DUMMY_PROVIDER_KEY,
|
|
496
|
+
provider_account_id=oauth_id,
|
|
497
|
+
provider_email=email,
|
|
498
|
+
user_id=user.id,
|
|
499
|
+
)
|
|
500
|
+
db_session.add(oauth_account)
|
|
433
501
|
|
|
434
502
|
await db_session.commit()
|
|
435
503
|
|
skrift/db/models/__init__.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
from skrift.db.models.oauth_account import OAuthAccount
|
|
1
2
|
from skrift.db.models.page import Page
|
|
2
3
|
from skrift.db.models.role import Role, RolePermission, user_roles
|
|
3
4
|
from skrift.db.models.setting import Setting
|
|
4
5
|
from skrift.db.models.user import User
|
|
5
6
|
|
|
6
|
-
__all__ = ["Page", "Role", "RolePermission", "Setting", "User", "user_roles"]
|
|
7
|
+
__all__ = ["OAuthAccount", "Page", "Role", "RolePermission", "Setting", "User", "user_roles"]
|
|
@@ -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=
|
|
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"
|
skrift/setup/controller.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
88
|
-
|
|
97
|
+
if can_connect:
|
|
98
|
+
# Database is configured - go through configuring page to run migrations
|
|
99
|
+
return Redirect(path="/setup/configuring")
|
|
89
100
|
|
|
90
|
-
#
|
|
91
|
-
|
|
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
|
-
#
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
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/
|
|
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
|
|
728
|
+
# Step 1: Check if OAuth account already exists
|
|
627
729
|
result = await db_session.execute(
|
|
628
|
-
select(
|
|
730
|
+
select(OAuthAccount)
|
|
731
|
+
.options(selectinload(OAuthAccount.user))
|
|
732
|
+
.where(OAuthAccount.provider == provider, OAuthAccount.provider_account_id == oauth_id)
|
|
629
733
|
)
|
|
630
|
-
|
|
734
|
+
oauth_account = result.scalar_one_or_none()
|
|
631
735
|
|
|
632
|
-
if
|
|
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
|
-
#
|
|
639
|
-
user =
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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/state.py
CHANGED
|
@@ -3,17 +3,36 @@
|
|
|
3
3
|
This module implements a two-tier detection strategy:
|
|
4
4
|
1. Pre-database check: Can we connect to a database?
|
|
5
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.
|
|
6
8
|
"""
|
|
7
9
|
|
|
8
10
|
import os
|
|
11
|
+
import subprocess
|
|
9
12
|
from enum import Enum
|
|
10
13
|
from pathlib import Path
|
|
11
|
-
|
|
14
|
+
import yaml
|
|
12
15
|
from sqlalchemy import text
|
|
13
16
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
|
14
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
|
+
|
|
15
30
|
from skrift.config import get_config_path
|
|
16
|
-
from skrift.db.services.setting_service import
|
|
31
|
+
from skrift.db.services.setting_service import (
|
|
32
|
+
SETUP_COMPLETED_AT_KEY,
|
|
33
|
+
SITE_NAME_KEY,
|
|
34
|
+
get_setting,
|
|
35
|
+
)
|
|
17
36
|
|
|
18
37
|
|
|
19
38
|
class SetupStep(Enum):
|
|
@@ -103,6 +122,167 @@ async def is_setup_complete(db_session: AsyncSession) -> bool:
|
|
|
103
122
|
return False
|
|
104
123
|
|
|
105
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
|
+
|
|
106
286
|
async def get_setup_step(db_session: AsyncSession | None = None) -> SetupStep:
|
|
107
287
|
"""Determine which setup step the user should be on.
|
|
108
288
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
{% extends "setup/base.html" %}
|
|
2
|
+
|
|
3
|
+
{% block title %}Configuring Database - Skrift{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block head %}
|
|
6
|
+
<style>
|
|
7
|
+
.configuring-content {
|
|
8
|
+
text-align: center;
|
|
9
|
+
padding: 2rem 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.spinner {
|
|
13
|
+
width: 48px;
|
|
14
|
+
height: 48px;
|
|
15
|
+
border: 4px solid var(--color-border);
|
|
16
|
+
border-top-color: var(--color-primary);
|
|
17
|
+
border-radius: 50%;
|
|
18
|
+
animation: spin 1s linear infinite;
|
|
19
|
+
margin: 0 auto 1.5rem;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.spinner.complete {
|
|
23
|
+
border-color: var(--color-success);
|
|
24
|
+
border-top-color: var(--color-success);
|
|
25
|
+
animation: none;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.spinner.error {
|
|
29
|
+
border-color: var(--color-error);
|
|
30
|
+
border-top-color: var(--color-error);
|
|
31
|
+
animation: none;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@keyframes spin {
|
|
35
|
+
to { transform: rotate(360deg); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.status-icon {
|
|
39
|
+
width: 48px;
|
|
40
|
+
height: 48px;
|
|
41
|
+
margin: 0 auto 1.5rem;
|
|
42
|
+
font-size: 48px;
|
|
43
|
+
line-height: 1;
|
|
44
|
+
display: none;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.status-icon.complete {
|
|
48
|
+
display: block;
|
|
49
|
+
color: var(--color-success);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.status-icon.error {
|
|
53
|
+
display: block;
|
|
54
|
+
color: var(--color-error);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.status-message {
|
|
58
|
+
font-size: 1.125rem;
|
|
59
|
+
margin-bottom: 0.5rem;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.status-detail {
|
|
63
|
+
color: var(--color-text-muted);
|
|
64
|
+
font-size: 0.875rem;
|
|
65
|
+
min-height: 1.25rem;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.form-actions button:disabled {
|
|
69
|
+
opacity: 0.5;
|
|
70
|
+
cursor: not-allowed;
|
|
71
|
+
}
|
|
72
|
+
</style>
|
|
73
|
+
{% endblock %}
|
|
74
|
+
|
|
75
|
+
{% block content %}
|
|
76
|
+
<h2>Configuring Database</h2>
|
|
77
|
+
<p class="description">Setting up your database and running migrations.</p>
|
|
78
|
+
|
|
79
|
+
<div class="configuring-content">
|
|
80
|
+
<div class="spinner" id="spinner"></div>
|
|
81
|
+
<div class="status-icon" id="status-icon"></div>
|
|
82
|
+
<div class="status-message" id="status-message">Initializing...</div>
|
|
83
|
+
<div class="status-detail" id="status-detail"></div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div class="form-actions">
|
|
87
|
+
<a href="/setup/database" role="button" class="btn-secondary">Back</a>
|
|
88
|
+
<button type="button" id="next-button" disabled>Continue</button>
|
|
89
|
+
</div>
|
|
90
|
+
{% endblock %}
|
|
91
|
+
|
|
92
|
+
{% block scripts %}
|
|
93
|
+
<script>
|
|
94
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
95
|
+
const spinner = document.getElementById('spinner');
|
|
96
|
+
const statusIcon = document.getElementById('status-icon');
|
|
97
|
+
const statusMessage = document.getElementById('status-message');
|
|
98
|
+
const statusDetail = document.getElementById('status-detail');
|
|
99
|
+
const nextButton = document.getElementById('next-button');
|
|
100
|
+
|
|
101
|
+
let nextStep = 'auth'; // Default fallback
|
|
102
|
+
|
|
103
|
+
function setComplete(step) {
|
|
104
|
+
spinner.style.display = 'none';
|
|
105
|
+
statusIcon.textContent = '\u2713';
|
|
106
|
+
statusIcon.classList.add('complete');
|
|
107
|
+
statusMessage.textContent = 'Database configured successfully!';
|
|
108
|
+
statusDetail.textContent = '';
|
|
109
|
+
nextButton.disabled = false;
|
|
110
|
+
if (step) {
|
|
111
|
+
nextStep = step;
|
|
112
|
+
}
|
|
113
|
+
nextButton.onclick = function() {
|
|
114
|
+
window.location.href = '/setup/' + nextStep;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function setError(message) {
|
|
119
|
+
spinner.style.display = 'none';
|
|
120
|
+
statusIcon.textContent = '\u2717';
|
|
121
|
+
statusIcon.classList.add('error');
|
|
122
|
+
statusMessage.textContent = 'Configuration failed';
|
|
123
|
+
statusDetail.textContent = message || 'An error occurred during setup.';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function setStatus(message, detail) {
|
|
127
|
+
statusMessage.textContent = message;
|
|
128
|
+
statusDetail.textContent = detail || '';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Connect to SSE endpoint
|
|
132
|
+
const eventSource = new EventSource('/setup/configuring/status');
|
|
133
|
+
|
|
134
|
+
eventSource.onmessage = function(event) {
|
|
135
|
+
const data = JSON.parse(event.data);
|
|
136
|
+
|
|
137
|
+
if (data.status === 'running') {
|
|
138
|
+
setStatus(data.message, data.detail);
|
|
139
|
+
} else if (data.status === 'complete') {
|
|
140
|
+
setComplete(data.next_step);
|
|
141
|
+
eventSource.close();
|
|
142
|
+
} else if (data.status === 'error') {
|
|
143
|
+
setError(data.message);
|
|
144
|
+
eventSource.close();
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
eventSource.onerror = function() {
|
|
149
|
+
// Check if we were successful before the connection closed
|
|
150
|
+
if (!nextButton.disabled) {
|
|
151
|
+
return; // Already complete, ignore the error
|
|
152
|
+
}
|
|
153
|
+
setError('Connection lost. Please refresh the page.');
|
|
154
|
+
eventSource.close();
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
</script>
|
|
158
|
+
{% endblock %}
|
|
@@ -1,33 +1,35 @@
|
|
|
1
1
|
skrift/__init__.py,sha256=eXE5PFVkJpH5XsV_ZlrTIeFPUPrmcHYAj4GpRS3R5PY,29
|
|
2
2
|
skrift/__main__.py,sha256=Fs17xxkqTjZpJn9MMC6pzkW4sOzNhV5RGLsA2ONPFww,271
|
|
3
|
-
skrift/alembic.ini,sha256=
|
|
3
|
+
skrift/alembic.ini,sha256=mYguI6CbMCTyfHctsGiTyf9Z5gv21FdeI3qtfgOHO3A,1815
|
|
4
4
|
skrift/asgi.py,sha256=U-9p_JlWlwjrhKJzAlBHpQ3DvTB2uIcvZe3-5Gs87Ec,19248
|
|
5
|
-
skrift/cli.py,sha256=
|
|
5
|
+
skrift/cli.py,sha256=DB_MssHeBAvpm7DVgd3V-BQr5BjBqSO0O_0vm15PZJ8,1941
|
|
6
6
|
skrift/config.py,sha256=7Jqqm9jXff7rIRY83C4jchDVk_1UkpGdKATX3JgX974,7423
|
|
7
7
|
skrift/admin/__init__.py,sha256=x81Cj_ilVmv6slaMl16HHyT_AgrnLxKEWkS0RPa4V9s,289
|
|
8
8
|
skrift/admin/controller.py,sha256=5ZDypvKHXLNDESsKNsdsH2E3Si5OqlpzttFl7Ot8aF0,15651
|
|
9
9
|
skrift/admin/navigation.py,sha256=VwttFoIUIJy5rONKIkJd5w4CNkUpeK22_OfLGHecN34,3382
|
|
10
|
-
skrift/alembic/env.py,sha256=
|
|
10
|
+
skrift/alembic/env.py,sha256=GaQx7D-3f0zVTV4YJNhN0GOfqHL99N4VBfMzjZJz0Bc,2673
|
|
11
11
|
skrift/alembic/script.py.mako,sha256=MEqL-2qATlST9TAOeYgscMn1uy6HUS9NFvDgl93dMj8,635
|
|
12
12
|
skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py,sha256=X6w1vbVKFurhEcblTLGO4Nd_IKkMVtb8TsptV4gpAJ4,2750
|
|
13
13
|
skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py,sha256=yCGjnMTpfSMHVYQCsPTD6wdDI8pJSUtGRESO6bz0KfU,2672
|
|
14
14
|
skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py,sha256=96TCvvQbYk3RGpZdEQ0x4bIDXxBLc3Gv7VTItTq9T54,850
|
|
15
15
|
skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py,sha256=XJ55LhwzgN07MAREk6w4Bx2Vu3KvMe_bDkIjgE6EfqM,1223
|
|
16
16
|
skrift/alembic/versions/20260122_200000_add_settings_table.py,sha256=aG4pxp18fwonGAWt7EoT0XaiIEmu7zltSrj8e2a8QZU,1310
|
|
17
|
+
skrift/alembic/versions/20260129_add_oauth_accounts.py,sha256=SJYQl0cfyJnUKLzsbGqu97Kcm7waPhxmpyARJXB3Z1Q,5240
|
|
17
18
|
skrift/auth/__init__.py,sha256=uHMqty3dgDSYlReVT96WhygzH6qNAWSVDWoxumzxmsA,1155
|
|
18
19
|
skrift/auth/guards.py,sha256=QePajHsGnJ4R_hlhzblr5IoAgZcY5jzeZ64bJwDL9hM,4451
|
|
19
20
|
skrift/auth/roles.py,sha256=jpqK4qaavqAhJRxhptm2x5mUb6KkwEALaml8sEH4Sug,2267
|
|
20
21
|
skrift/auth/services.py,sha256=h6GTXdN5UMRYglnaFz4asMoutVkSSAyL3_Vt56N26pA,5441
|
|
21
22
|
skrift/controllers/__init__.py,sha256=bVr0tsSGz7jBi002Lqd1AA1FQd7ZA_IagsqTKpiHiK0,147
|
|
22
|
-
skrift/controllers/auth.py,sha256=
|
|
23
|
+
skrift/controllers/auth.py,sha256=2XQSljDnqdk1NZZTfYLBAqfZb4Ref2p3Uy2maUkKJgY,18881
|
|
23
24
|
skrift/controllers/web.py,sha256=vmoBS1u5G9gCBu65S49yqZn_WBKlmsqlcvX5tYXTKnE,2348
|
|
24
25
|
skrift/db/__init__.py,sha256=uSghyDFT2K4SFiEqUzdjCGzWpS-Oy6Sd1FUappau-v0,52
|
|
25
26
|
skrift/db/base.py,sha256=QJplFj9235kZdScASEpvyNHln6YW2hqbHwJEYZ3OSsc,173
|
|
26
|
-
skrift/db/models/__init__.py,sha256=
|
|
27
|
+
skrift/db/models/__init__.py,sha256=wFF9YWe7rhIWIzRuMtAWVDcZdJsNcKnjfhKbLZw9JVs,341
|
|
28
|
+
skrift/db/models/oauth_account.py,sha256=kLb-1Do8a7tluW3f-uhA_BpgkZ9h10kg_8v0jInDrwY,1216
|
|
27
29
|
skrift/db/models/page.py,sha256=CI5W2sWq0zbKHxfwH5TpmGdJ4zlTicckzpdZtaqjJVE,1019
|
|
28
30
|
skrift/db/models/role.py,sha256=VkwkF3XWemmFtkUpQRk6RTIBfcukrU-0PUPCeo8x834,1768
|
|
29
31
|
skrift/db/models/setting.py,sha256=Am4HTyq2LFR408R6BZb--slys1xM8YLYq9HTq_gybD0,392
|
|
30
|
-
skrift/db/models/user.py,sha256=
|
|
32
|
+
skrift/db/models/user.py,sha256=kkR3h0CphUyvOGJOpiZH6Qp28LqW2OriwVTfybs7vuY,1338
|
|
31
33
|
skrift/db/services/__init__.py,sha256=qAC24IPOYg6AampUWonmLeajljQRMwUw0lgXksQG6Nk,69
|
|
32
34
|
skrift/db/services/page_service.py,sha256=cXJ-urV7LjUDoVsT1P52f6wcOsur3T-pvqKkGG4xlvI,5246
|
|
33
35
|
skrift/db/services/setting_service.py,sha256=eqFxukn8QFrDHQbaBILOLjP1nr34JPIPFT_NnhmeaTY,5613
|
|
@@ -36,10 +38,10 @@ skrift/lib/exceptions.py,sha256=p8ceLIQCc7agCwW6-mhBDAuMAMxZDcf9TDLC6PfztU4,5803
|
|
|
36
38
|
skrift/lib/template.py,sha256=4_urkRfvth75yNeQ5TyGTHvkvs3vVef7TcwZx0k285k,4226
|
|
37
39
|
skrift/setup/__init__.py,sha256=3VjFPMES5y0M5cQ9R4C1xazqiEPEDqTPjX9-3rBMXnA,478
|
|
38
40
|
skrift/setup/config_writer.py,sha256=YFH3FVjXN7Rum2fzGVPAQRkjdc9b0bHECDqMKYiEkhg,6347
|
|
39
|
-
skrift/setup/controller.py,sha256=
|
|
41
|
+
skrift/setup/controller.py,sha256=v0Ey8T7ptJ5A3vOqQ1TUAXH1bQwA0288J5uyUWMihsw,34250
|
|
40
42
|
skrift/setup/middleware.py,sha256=Nai8ZG2vHldngmAhq7kWzAwKRNcP5tHKhJHa5dCh404,2941
|
|
41
43
|
skrift/setup/providers.py,sha256=0BFKB6168NcmtXxFF6ofHgEDMQD2FbXkexsqrARVtDI,7967
|
|
42
|
-
skrift/setup/state.py,sha256=
|
|
44
|
+
skrift/setup/state.py,sha256=RMe9LtIjzDoOm9u-Nk5-KAnr_JBiQIjWDpTP9E30ezc,9304
|
|
43
45
|
skrift/static/css/style.css,sha256=sJ7-y8nrUdB5EB5_CyjWo1CTnqmYq0UgMZYMjxw0988,20824
|
|
44
46
|
skrift/templates/base.html,sha256=4bg4s4VdES0dSvhJYLgrfrN26ynqeq1-3jyKPkWWVWk,2065
|
|
45
47
|
skrift/templates/error-404.html,sha256=sJrDaF3Or3Nyki8mxo3wBxLLzgy4wkB9p9wdS8pRA6k,409
|
|
@@ -60,10 +62,11 @@ skrift/templates/setup/admin.html,sha256=BSIztZT2iqxVSW23Tfg7ZM51SdGrKL422CR05DP
|
|
|
60
62
|
skrift/templates/setup/auth.html,sha256=0DVL0kU6DlJ2pWe4zwc3DsIVfBAdqpMJxPYvS4zm4OM,4953
|
|
61
63
|
skrift/templates/setup/base.html,sha256=LTXqbnHMvx1wDsxFvo4BSieBPD9pcLMj6NM4ZGzErFM,10372
|
|
62
64
|
skrift/templates/setup/complete.html,sha256=oyT-rYPl0uuyOjPXgNeLr8YoptW9QjHTlScZSViDvTk,630
|
|
65
|
+
skrift/templates/setup/configuring.html,sha256=2KHW9h2BrJgL_kO5IizbAYs4pnFLyRf76IQvEj_cNRM,4607
|
|
63
66
|
skrift/templates/setup/database.html,sha256=gU4-315-QraHa2Eq4Fh3b55QpOM2CkJzh27_Yz13frA,5495
|
|
64
67
|
skrift/templates/setup/restart.html,sha256=GHg31F_e2uLFhWUzJoalk0Y0oYLqsFWyZXWKX3mblbY,1355
|
|
65
68
|
skrift/templates/setup/site.html,sha256=PSOH-q1-ZBl47iSW9-Ad6lEfJn_fzdGD3Pk4vb3xgK4,1680
|
|
66
|
-
skrift-0.1.
|
|
67
|
-
skrift-0.1.
|
|
68
|
-
skrift-0.1.
|
|
69
|
-
skrift-0.1.
|
|
69
|
+
skrift-0.1.0a3.dist-info/METADATA,sha256=BNvNuT2HgfrWYz5Eh5zKmp-9_6JypPmEM8PiALxdeDg,6435
|
|
70
|
+
skrift-0.1.0a3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
71
|
+
skrift-0.1.0a3.dist-info/entry_points.txt,sha256=4AIrmbeWKOdZnvTsKT3US6N3X9rrgk9jEDsYOPEZ1AE,74
|
|
72
|
+
skrift-0.1.0a3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|