skrift 0.1.0a1__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.
Files changed (68) hide show
  1. skrift/__init__.py +1 -0
  2. skrift/__main__.py +17 -0
  3. skrift/admin/__init__.py +11 -0
  4. skrift/admin/controller.py +452 -0
  5. skrift/admin/navigation.py +105 -0
  6. skrift/alembic/env.py +91 -0
  7. skrift/alembic/script.py.mako +26 -0
  8. skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py +70 -0
  9. skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py +57 -0
  10. skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py +31 -0
  11. skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py +43 -0
  12. skrift/alembic/versions/20260122_200000_add_settings_table.py +38 -0
  13. skrift/alembic.ini +77 -0
  14. skrift/asgi.py +545 -0
  15. skrift/auth/__init__.py +58 -0
  16. skrift/auth/guards.py +130 -0
  17. skrift/auth/roles.py +94 -0
  18. skrift/auth/services.py +184 -0
  19. skrift/cli.py +45 -0
  20. skrift/config.py +192 -0
  21. skrift/controllers/__init__.py +4 -0
  22. skrift/controllers/auth.py +371 -0
  23. skrift/controllers/web.py +67 -0
  24. skrift/db/__init__.py +3 -0
  25. skrift/db/base.py +7 -0
  26. skrift/db/models/__init__.py +6 -0
  27. skrift/db/models/page.py +26 -0
  28. skrift/db/models/role.py +56 -0
  29. skrift/db/models/setting.py +13 -0
  30. skrift/db/models/user.py +36 -0
  31. skrift/db/services/__init__.py +1 -0
  32. skrift/db/services/page_service.py +217 -0
  33. skrift/db/services/setting_service.py +206 -0
  34. skrift/lib/__init__.py +3 -0
  35. skrift/lib/exceptions.py +168 -0
  36. skrift/lib/template.py +108 -0
  37. skrift/setup/__init__.py +14 -0
  38. skrift/setup/config_writer.py +211 -0
  39. skrift/setup/controller.py +751 -0
  40. skrift/setup/middleware.py +89 -0
  41. skrift/setup/providers.py +163 -0
  42. skrift/setup/state.py +134 -0
  43. skrift/static/css/style.css +998 -0
  44. skrift/templates/admin/admin.html +19 -0
  45. skrift/templates/admin/base.html +24 -0
  46. skrift/templates/admin/pages/edit.html +32 -0
  47. skrift/templates/admin/pages/list.html +62 -0
  48. skrift/templates/admin/settings/site.html +32 -0
  49. skrift/templates/admin/users/list.html +58 -0
  50. skrift/templates/admin/users/roles.html +42 -0
  51. skrift/templates/auth/login.html +125 -0
  52. skrift/templates/base.html +52 -0
  53. skrift/templates/error-404.html +19 -0
  54. skrift/templates/error-500.html +19 -0
  55. skrift/templates/error.html +19 -0
  56. skrift/templates/index.html +9 -0
  57. skrift/templates/page.html +26 -0
  58. skrift/templates/setup/admin.html +24 -0
  59. skrift/templates/setup/auth.html +110 -0
  60. skrift/templates/setup/base.html +407 -0
  61. skrift/templates/setup/complete.html +17 -0
  62. skrift/templates/setup/database.html +125 -0
  63. skrift/templates/setup/restart.html +28 -0
  64. skrift/templates/setup/site.html +39 -0
  65. skrift-0.1.0a1.dist-info/METADATA +233 -0
  66. skrift-0.1.0a1.dist-info/RECORD +68 -0
  67. skrift-0.1.0a1.dist-info/WHEEL +4 -0
  68. skrift-0.1.0a1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,89 @@
1
+ """Setup wizard middleware for redirecting users to the setup wizard."""
2
+
3
+ from litestar.response import Redirect
4
+ from litestar.types import ASGIApp, Receive, Scope, Send
5
+
6
+ # Paths that should be accessible during setup
7
+ SETUP_ALLOWED_PATHS = (
8
+ "/setup",
9
+ "/static",
10
+ "/auth", # Needed for OAuth callbacks during admin creation
11
+ )
12
+
13
+
14
+ class SetupMiddleware:
15
+ """ASGI middleware to redirect users to setup wizard if not configured."""
16
+
17
+ def __init__(self, app: ASGIApp, setup_complete: bool = False, use_app_state: bool = False) -> None:
18
+ """Initialize the middleware.
19
+
20
+ Args:
21
+ app: The ASGI application
22
+ setup_complete: Whether setup has been completed (static mode)
23
+ use_app_state: Whether to check app.state.setup_complete at runtime
24
+ """
25
+ self.app = app
26
+ self._setup_complete = setup_complete
27
+ self._use_app_state = use_app_state
28
+
29
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
30
+ """Process the request."""
31
+ if scope["type"] != "http":
32
+ await self.app(scope, receive, send)
33
+ return
34
+
35
+ path = scope["path"]
36
+
37
+ # Check setup complete status - either from app state (dynamic) or instance var (static)
38
+ setup_complete = self._setup_complete
39
+ if self._use_app_state and "app" in scope:
40
+ litestar_app = scope["app"]
41
+ setup_complete = getattr(litestar_app.state, "setup_complete", self._setup_complete)
42
+
43
+ # If setup is complete, block access to /setup
44
+ if setup_complete:
45
+ if path.startswith("/setup"):
46
+ # Redirect setup routes to home after setup is complete
47
+ response = Redirect(path="/")
48
+ await response(scope, receive, send)
49
+ return
50
+ await self.app(scope, receive, send)
51
+ return
52
+
53
+ # Setup not complete - check if path is allowed
54
+ if any(path.startswith(allowed) for allowed in SETUP_ALLOWED_PATHS):
55
+ await self.app(scope, receive, send)
56
+ return
57
+
58
+ # Redirect to setup wizard
59
+ response = Redirect(path="/setup")
60
+ await response(scope, receive, send)
61
+
62
+
63
+ def create_setup_middleware_factory(setup_complete: bool):
64
+ """Create a middleware factory for the setup middleware (static mode).
65
+
66
+ Args:
67
+ setup_complete: Whether setup has been completed
68
+
69
+ Returns:
70
+ Middleware factory function
71
+ """
72
+
73
+ def factory(app: ASGIApp) -> SetupMiddleware:
74
+ return SetupMiddleware(app, setup_complete=setup_complete)
75
+
76
+ return factory
77
+
78
+
79
+ def create_dynamic_setup_middleware_factory():
80
+ """Create a middleware factory that checks app state at runtime.
81
+
82
+ Returns:
83
+ Middleware factory function
84
+ """
85
+
86
+ def factory(app: ASGIApp) -> SetupMiddleware:
87
+ return SetupMiddleware(app, setup_complete=False, use_app_state=True)
88
+
89
+ return factory
@@ -0,0 +1,163 @@
1
+ """OAuth provider definitions and configuration for the setup wizard."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class OAuthProviderInfo:
8
+ """Information about an OAuth provider."""
9
+
10
+ name: str
11
+ auth_url: str
12
+ token_url: str
13
+ userinfo_url: str
14
+ scopes: list[str]
15
+ console_url: str
16
+ fields: list[dict]
17
+ instructions: str
18
+ icon: str = ""
19
+
20
+
21
+ OAUTH_PROVIDERS = {
22
+ "google": OAuthProviderInfo(
23
+ name="Google",
24
+ auth_url="https://accounts.google.com/o/oauth2/v2/auth",
25
+ token_url="https://oauth2.googleapis.com/token",
26
+ userinfo_url="https://www.googleapis.com/oauth2/v2/userinfo",
27
+ scopes=["openid", "email", "profile"],
28
+ console_url="https://console.cloud.google.com/apis/credentials",
29
+ fields=[
30
+ {"key": "client_id", "label": "Client ID", "type": "text"},
31
+ {"key": "client_secret", "label": "Client Secret", "type": "password"},
32
+ ],
33
+ instructions="""
34
+ 1. Go to the Google Cloud Console
35
+ 2. Create a new project or select an existing one
36
+ 3. Enable the Google+ API
37
+ 4. Go to Credentials → Create Credentials → OAuth Client ID
38
+ 5. Choose "Web application"
39
+ 6. Add the redirect URI shown below
40
+ 7. Copy the Client ID and Client Secret
41
+ """.strip(),
42
+ icon="google",
43
+ ),
44
+ "github": OAuthProviderInfo(
45
+ name="GitHub",
46
+ auth_url="https://github.com/login/oauth/authorize",
47
+ token_url="https://github.com/login/oauth/access_token",
48
+ userinfo_url="https://api.github.com/user",
49
+ scopes=["read:user", "user:email"],
50
+ console_url="https://github.com/settings/developers",
51
+ fields=[
52
+ {"key": "client_id", "label": "Client ID", "type": "text"},
53
+ {"key": "client_secret", "label": "Client Secret", "type": "password"},
54
+ ],
55
+ instructions="""
56
+ 1. Go to GitHub Settings → Developer settings → OAuth Apps
57
+ 2. Click "New OAuth App"
58
+ 3. Fill in the application details
59
+ 4. Add the redirect URI shown below as the callback URL
60
+ 5. Copy the Client ID and generate a Client Secret
61
+ """.strip(),
62
+ icon="github",
63
+ ),
64
+ "microsoft": OAuthProviderInfo(
65
+ name="Microsoft",
66
+ auth_url="https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
67
+ token_url="https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
68
+ userinfo_url="https://graph.microsoft.com/v1.0/me",
69
+ scopes=["openid", "email", "profile"],
70
+ console_url="https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
71
+ fields=[
72
+ {"key": "client_id", "label": "Client (Application) ID", "type": "text"},
73
+ {"key": "client_secret", "label": "Client Secret", "type": "password"},
74
+ {
75
+ "key": "tenant_id",
76
+ "label": "Tenant ID",
77
+ "type": "text",
78
+ "placeholder": "common",
79
+ "optional": True,
80
+ },
81
+ ],
82
+ instructions="""
83
+ 1. Go to Azure Portal → App registrations
84
+ 2. Click "New registration"
85
+ 3. Enter a name and select the account types you want to support
86
+ 4. Add the redirect URI shown below (Web platform)
87
+ 5. Under Certificates & secrets, create a new client secret
88
+ 6. Copy the Application (client) ID and secret value
89
+ 7. For Tenant ID: use "common" for any Microsoft account, or your specific tenant ID
90
+ """.strip(),
91
+ icon="microsoft",
92
+ ),
93
+ "discord": OAuthProviderInfo(
94
+ name="Discord",
95
+ auth_url="https://discord.com/api/oauth2/authorize",
96
+ token_url="https://discord.com/api/oauth2/token",
97
+ userinfo_url="https://discord.com/api/users/@me",
98
+ scopes=["identify", "email"],
99
+ console_url="https://discord.com/developers/applications",
100
+ fields=[
101
+ {"key": "client_id", "label": "Client ID", "type": "text"},
102
+ {"key": "client_secret", "label": "Client Secret", "type": "password"},
103
+ ],
104
+ instructions="""
105
+ 1. Go to Discord Developer Portal
106
+ 2. Create a new application
107
+ 3. Go to OAuth2 → General
108
+ 4. Add the redirect URI shown below
109
+ 5. Copy the Client ID and Client Secret
110
+ """.strip(),
111
+ icon="discord",
112
+ ),
113
+ "facebook": OAuthProviderInfo(
114
+ name="Facebook",
115
+ auth_url="https://www.facebook.com/v18.0/dialog/oauth",
116
+ token_url="https://graph.facebook.com/v18.0/oauth/access_token",
117
+ userinfo_url="https://graph.facebook.com/me?fields=id,name,email,picture",
118
+ scopes=["email", "public_profile"],
119
+ console_url="https://developers.facebook.com/apps/",
120
+ fields=[
121
+ {"key": "client_id", "label": "App ID", "type": "text"},
122
+ {"key": "client_secret", "label": "App Secret", "type": "password"},
123
+ ],
124
+ instructions="""
125
+ 1. Go to Meta for Developers
126
+ 2. Create a new app (Consumer type)
127
+ 3. Add Facebook Login product
128
+ 4. Go to Settings → Basic to find App ID and Secret
129
+ 5. Add the redirect URI shown below in Facebook Login settings
130
+ """.strip(),
131
+ icon="facebook",
132
+ ),
133
+ "twitter": OAuthProviderInfo(
134
+ name="X (Twitter)",
135
+ auth_url="https://twitter.com/i/oauth2/authorize",
136
+ token_url="https://api.twitter.com/2/oauth2/token",
137
+ userinfo_url="https://api.twitter.com/2/users/me",
138
+ scopes=["users.read", "tweet.read"],
139
+ console_url="https://developer.twitter.com/en/portal/dashboard",
140
+ fields=[
141
+ {"key": "client_id", "label": "Client ID", "type": "text"},
142
+ {"key": "client_secret", "label": "Client Secret", "type": "password"},
143
+ ],
144
+ instructions="""
145
+ 1. Go to X Developer Portal
146
+ 2. Create a new project and app
147
+ 3. Enable OAuth 2.0 in User authentication settings
148
+ 4. Add the redirect URI shown below
149
+ 5. Copy the Client ID and Client Secret (OAuth 2.0)
150
+ """.strip(),
151
+ icon="twitter",
152
+ ),
153
+ }
154
+
155
+
156
+ def get_provider_info(provider: str) -> OAuthProviderInfo | None:
157
+ """Get provider info by key."""
158
+ return OAUTH_PROVIDERS.get(provider)
159
+
160
+
161
+ def get_all_providers() -> dict[str, OAuthProviderInfo]:
162
+ """Get all available OAuth providers."""
163
+ return OAUTH_PROVIDERS.copy()
skrift/setup/state.py ADDED
@@ -0,0 +1,134 @@
1
+ """Setup state detection for the Skrift setup wizard.
2
+
3
+ This module implements a two-tier detection strategy:
4
+ 1. Pre-database check: Can we connect to a database?
5
+ 2. Post-database check: Is setup complete (check for setup_completed_at setting)?
6
+ """
7
+
8
+ import os
9
+ from enum import Enum
10
+ from pathlib import Path
11
+
12
+ from sqlalchemy import text
13
+ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
14
+
15
+ from skrift.db.services.setting_service import SETUP_COMPLETED_AT_KEY, get_setting
16
+
17
+
18
+ class SetupStep(Enum):
19
+ """Wizard steps."""
20
+
21
+ DATABASE = "database"
22
+ AUTH = "auth"
23
+ SITE = "site"
24
+ ADMIN = "admin"
25
+ COMPLETE = "complete"
26
+
27
+
28
+ def app_yaml_exists() -> bool:
29
+ """Check if app.yaml exists in the current working directory."""
30
+ return (Path.cwd() / "app.yaml").exists()
31
+
32
+
33
+ def get_database_url_from_yaml() -> str | None:
34
+ """Try to get the database URL from app.yaml, returning None if not configured.
35
+
36
+ If the URL is an env var reference that isn't set, falls back to checking
37
+ for local SQLite database files.
38
+ """
39
+ import yaml
40
+
41
+ config_path = Path.cwd() / "app.yaml"
42
+ if not config_path.exists():
43
+ return None
44
+
45
+ try:
46
+ with open(config_path, "r") as f:
47
+ config = yaml.safe_load(f)
48
+
49
+ if not config or "db" not in config:
50
+ return None
51
+
52
+ db_url = config["db"].get("url")
53
+ if not db_url:
54
+ return None
55
+
56
+ # If it's an env var reference, try to resolve it
57
+ if db_url.startswith("$"):
58
+ env_var = db_url[1:]
59
+ resolved = os.environ.get(env_var)
60
+ if resolved:
61
+ return resolved
62
+
63
+ # Fallback: check for local SQLite database files
64
+ for db_file in ["./app.db", "./data.db", "./skrift.db"]:
65
+ if Path(db_file).exists():
66
+ return f"sqlite+aiosqlite:///{db_file}"
67
+
68
+ return None
69
+
70
+ return db_url
71
+ except Exception:
72
+ return None
73
+
74
+
75
+ async def can_connect_to_database() -> tuple[bool, str | None]:
76
+ """Test if we can connect to the database.
77
+
78
+ Returns:
79
+ Tuple of (success, error_message)
80
+ """
81
+ db_url = get_database_url_from_yaml()
82
+ if not db_url:
83
+ return False, "Database URL not configured"
84
+
85
+ try:
86
+ engine = create_async_engine(db_url)
87
+ async with engine.connect() as conn:
88
+ await conn.execute(text("SELECT 1"))
89
+ await engine.dispose()
90
+ return True, None
91
+ except Exception as e:
92
+ return False, str(e)
93
+
94
+
95
+ async def is_setup_complete(db_session: AsyncSession) -> bool:
96
+ """Check if setup has been completed by looking for the setup_completed_at setting."""
97
+ try:
98
+ value = await get_setting(db_session, SETUP_COMPLETED_AT_KEY)
99
+ return value is not None
100
+ except Exception:
101
+ # Table might not exist yet
102
+ return False
103
+
104
+
105
+ async def get_setup_step(db_session: AsyncSession | None = None) -> SetupStep:
106
+ """Determine which setup step the user should be on.
107
+
108
+ Args:
109
+ db_session: Database session if available
110
+
111
+ Returns:
112
+ The appropriate setup step
113
+ """
114
+ # Pre-database check
115
+ if not app_yaml_exists():
116
+ return SetupStep.DATABASE
117
+
118
+ db_url = get_database_url_from_yaml()
119
+ if not db_url:
120
+ return SetupStep.DATABASE
121
+
122
+ can_connect, _ = await can_connect_to_database()
123
+ if not can_connect:
124
+ return SetupStep.DATABASE
125
+
126
+ # Post-database check - need a session
127
+ if db_session is None:
128
+ return SetupStep.DATABASE
129
+
130
+ if not await is_setup_complete(db_session):
131
+ # Check wizard progress stored in session (handled by controller)
132
+ return SetupStep.AUTH
133
+
134
+ return SetupStep.COMPLETE