vibetuner 2.26.9__py3-none-any.whl → 2.44.1__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.
Potentially problematic release.
This version of vibetuner might be problematic. Click here for more details.
- vibetuner/cli/__init__.py +2 -17
- vibetuner/cli/db.py +40 -0
- vibetuner/cli/run.py +126 -110
- vibetuner/cli/scaffold.py +7 -0
- vibetuner/config.py +55 -3
- vibetuner/frontend/__init__.py +1 -0
- vibetuner/frontend/lifespan.py +7 -2
- vibetuner/frontend/middleware.py +0 -25
- vibetuner/frontend/proxy.py +14 -0
- vibetuner/frontend/routes/debug.py +41 -31
- vibetuner/frontend/templates.py +81 -0
- vibetuner/mongo.py +55 -15
- vibetuner/paths.py +16 -13
- vibetuner/services/blob.py +31 -45
- vibetuner/services/email.py +14 -15
- vibetuner/services/s3_storage.py +454 -0
- vibetuner/sqlmodel.py +109 -0
- vibetuner/tasks/lifespan.py +7 -2
- vibetuner/tasks/worker.py +9 -8
- vibetuner/templates/frontend/email_sent.html.jinja +1 -1
- vibetuner/templates/frontend/index.html.jinja +2 -2
- vibetuner/templates/frontend/login.html.jinja +1 -1
- vibetuner/templates/frontend/user/edit.html.jinja +1 -1
- vibetuner/templates/frontend/user/profile.html.jinja +1 -1
- {vibetuner-2.26.9.dist-info → vibetuner-2.44.1.dist-info}/METADATA +39 -29
- {vibetuner-2.26.9.dist-info → vibetuner-2.44.1.dist-info}/RECORD +28 -24
- {vibetuner-2.26.9.dist-info → vibetuner-2.44.1.dist-info}/WHEEL +1 -1
- {vibetuner-2.26.9.dist-info → vibetuner-2.44.1.dist-info}/entry_points.txt +0 -0
vibetuner/cli/__init__.py
CHANGED
|
@@ -4,14 +4,13 @@ import importlib.metadata
|
|
|
4
4
|
import inspect
|
|
5
5
|
from functools import partial, wraps
|
|
6
6
|
from importlib import import_module
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
from typing import Annotated
|
|
9
7
|
|
|
10
8
|
import asyncer
|
|
11
9
|
import typer
|
|
12
10
|
from rich.console import Console
|
|
13
11
|
from rich.table import Table
|
|
14
12
|
|
|
13
|
+
from vibetuner.cli.db import db_app
|
|
15
14
|
from vibetuner.cli.run import run_app
|
|
16
15
|
from vibetuner.cli.scaffold import scaffold_app
|
|
17
16
|
from vibetuner.logging import LogLevel, logger, setup_logging
|
|
@@ -111,21 +110,7 @@ def version(
|
|
|
111
110
|
console.print(table)
|
|
112
111
|
|
|
113
112
|
|
|
114
|
-
|
|
115
|
-
def core_template_symlink(
|
|
116
|
-
target: Annotated[
|
|
117
|
-
Path,
|
|
118
|
-
typer.Argument(
|
|
119
|
-
help="Path where the 'core' symlink should be created or updated",
|
|
120
|
-
),
|
|
121
|
-
],
|
|
122
|
-
) -> None:
|
|
123
|
-
"""Create or update a 'core' symlink to the package templates directory."""
|
|
124
|
-
from vibetuner.paths import create_core_templates_symlink
|
|
125
|
-
|
|
126
|
-
create_core_templates_symlink(target)
|
|
127
|
-
|
|
128
|
-
|
|
113
|
+
app.add_typer(db_app, name="db")
|
|
129
114
|
app.add_typer(run_app, name="run")
|
|
130
115
|
app.add_typer(scaffold_app, name="scaffold")
|
|
131
116
|
|
vibetuner/cli/db.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# ABOUTME: Database management CLI commands for SQLModel.
|
|
2
|
+
# ABOUTME: Provides schema creation and other database utilities.
|
|
3
|
+
|
|
4
|
+
import asyncer
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
db_app = typer.Typer(help="Database management commands", no_args_is_help=True)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@db_app.command("create-schema")
|
|
12
|
+
def create_schema_cmd() -> None:
|
|
13
|
+
"""
|
|
14
|
+
Create database tables from SQLModel metadata.
|
|
15
|
+
|
|
16
|
+
This command creates all tables defined in your SQLModel models.
|
|
17
|
+
It's idempotent - existing tables are not modified.
|
|
18
|
+
|
|
19
|
+
Run this during initial setup or after adding new models.
|
|
20
|
+
"""
|
|
21
|
+
from importlib import import_module
|
|
22
|
+
|
|
23
|
+
from vibetuner.logging import logger
|
|
24
|
+
|
|
25
|
+
# Import app.models to register SQLModel tables before schema creation
|
|
26
|
+
try:
|
|
27
|
+
import_module("app.models")
|
|
28
|
+
except ModuleNotFoundError:
|
|
29
|
+
logger.warning("app.models not found. Only core models will be created.")
|
|
30
|
+
except ImportError as e:
|
|
31
|
+
logger.warning(f"Failed to import app.models: {e}")
|
|
32
|
+
|
|
33
|
+
async def _create() -> None:
|
|
34
|
+
from vibetuner.sqlmodel import create_schema
|
|
35
|
+
|
|
36
|
+
await create_schema()
|
|
37
|
+
|
|
38
|
+
typer.echo("Creating database schema...")
|
|
39
|
+
asyncer.runnify(_create)()
|
|
40
|
+
typer.echo("Database schema created successfully.")
|
vibetuner/cli/run.py
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
# ABOUTME: Run commands for starting the application in different modes
|
|
2
2
|
# ABOUTME: Supports dev/prod modes for frontend and worker services
|
|
3
|
+
import hashlib
|
|
3
4
|
import os
|
|
4
|
-
from
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated, Literal
|
|
5
7
|
|
|
6
8
|
import typer
|
|
7
9
|
from rich.console import Console
|
|
8
10
|
|
|
11
|
+
from vibetuner.logging import logger
|
|
12
|
+
|
|
9
13
|
|
|
10
14
|
console = Console()
|
|
11
15
|
|
|
@@ -13,88 +17,148 @@ run_app = typer.Typer(
|
|
|
13
17
|
help="Run the application in different modes", no_args_is_help=True
|
|
14
18
|
)
|
|
15
19
|
|
|
20
|
+
DEFAULT_FRONTEND_PORT = 8000
|
|
21
|
+
DEFAULT_WORKER_PORT = 11111
|
|
16
22
|
|
|
17
|
-
@run_app.command(name="dev")
|
|
18
|
-
def dev(
|
|
19
|
-
service: Annotated[
|
|
20
|
-
str, typer.Argument(help="Service to run: 'frontend' or 'worker'")
|
|
21
|
-
] = "frontend",
|
|
22
|
-
port: int = typer.Option(
|
|
23
|
-
None, help="Port to run on (8000 for frontend, 11111 for worker)"
|
|
24
|
-
),
|
|
25
|
-
host: str = typer.Option("0.0.0.0", help="Host to bind to (frontend only)"), # noqa: S104
|
|
26
|
-
workers_count: int = typer.Option(
|
|
27
|
-
1, "--workers", help="Number of worker processes"
|
|
28
|
-
),
|
|
29
|
-
) -> None:
|
|
30
|
-
"""Run in development mode with hot reload (frontend or worker)."""
|
|
31
|
-
os.environ["DEBUG"] = "1"
|
|
32
23
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
24
|
+
def _compute_auto_port() -> int:
|
|
25
|
+
"""Compute deterministic port from current directory path."""
|
|
26
|
+
cwd = os.getcwd()
|
|
27
|
+
hash_bytes = hashlib.sha256(cwd.encode()).digest()
|
|
28
|
+
hash_int = int.from_bytes(hash_bytes[:4], "big")
|
|
29
|
+
return 8001 + (hash_int % 999)
|
|
36
30
|
|
|
37
|
-
worker_port = port if port else 11111
|
|
38
|
-
console.print(
|
|
39
|
-
f"[green]Starting worker in dev mode on port {worker_port}[/green]"
|
|
40
|
-
)
|
|
41
|
-
console.print("[dim]Hot reload enabled[/dim]")
|
|
42
31
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
verbose=True,
|
|
54
|
-
web=True,
|
|
55
|
-
host="0.0.0.0", # noqa: S104
|
|
56
|
-
port=worker_port,
|
|
32
|
+
def _run_worker(mode: Literal["dev", "prod"], port: int, workers: int) -> None:
|
|
33
|
+
"""Start the background worker process."""
|
|
34
|
+
from streaq.cli import main as streaq_main
|
|
35
|
+
|
|
36
|
+
from vibetuner.config import settings
|
|
37
|
+
|
|
38
|
+
if not settings.workers_available:
|
|
39
|
+
logger.warning("Redis URL not configured. Workers will not be started.")
|
|
40
|
+
console.print(
|
|
41
|
+
"[red]Error: Redis URL not configured. Workers will not be started.[/red]"
|
|
57
42
|
)
|
|
58
|
-
|
|
59
|
-
# Frontend mode
|
|
60
|
-
from pathlib import Path
|
|
43
|
+
raise typer.Exit(code=0)
|
|
61
44
|
|
|
62
|
-
|
|
63
|
-
from granian.constants import Interfaces
|
|
45
|
+
is_dev = mode == "dev"
|
|
64
46
|
|
|
65
|
-
|
|
47
|
+
if is_dev and workers > 1:
|
|
66
48
|
console.print(
|
|
67
|
-
|
|
49
|
+
"[yellow]Warning: Multiple workers not supported in dev mode, using 1[/yellow]"
|
|
68
50
|
)
|
|
51
|
+
workers = 1
|
|
52
|
+
|
|
53
|
+
console.print(f"[green]Starting worker in {mode} mode on port {port}[/green]")
|
|
54
|
+
if is_dev:
|
|
55
|
+
console.print("[dim]Hot reload enabled[/dim]")
|
|
56
|
+
else:
|
|
57
|
+
console.print(f"[dim]Workers: {workers}[/dim]")
|
|
58
|
+
|
|
59
|
+
streaq_main(
|
|
60
|
+
worker_path="vibetuner.tasks.worker.worker",
|
|
61
|
+
workers=workers,
|
|
62
|
+
reload=is_dev,
|
|
63
|
+
verbose=True if is_dev else settings.debug,
|
|
64
|
+
web=True,
|
|
65
|
+
host="0.0.0.0", # noqa: S104
|
|
66
|
+
port=port,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _run_frontend(
|
|
71
|
+
mode: Literal["dev", "prod"], host: str, port: int, workers: int
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Start the frontend server."""
|
|
74
|
+
from granian import Granian
|
|
75
|
+
from granian.constants import Interfaces
|
|
76
|
+
|
|
77
|
+
is_dev = mode == "dev"
|
|
78
|
+
|
|
79
|
+
console.print(f"[green]Starting frontend in {mode} mode on {host}:{port}[/green]")
|
|
80
|
+
console.print(f"[cyan]website reachable at http://localhost:{port}[/cyan]")
|
|
81
|
+
console.print(
|
|
82
|
+
f"[cyan]website reachable at https://{port}.localdev.alltuner.com:12000/[/cyan]"
|
|
83
|
+
)
|
|
84
|
+
if is_dev:
|
|
69
85
|
console.print("[dim]Watching for changes in src/ and templates/[/dim]")
|
|
86
|
+
else:
|
|
87
|
+
console.print(f"[dim]Workers: {workers}[/dim]")
|
|
70
88
|
|
|
71
|
-
|
|
72
|
-
|
|
89
|
+
reload_paths = (
|
|
90
|
+
[
|
|
73
91
|
Path("src/app"),
|
|
74
92
|
Path("templates/frontend"),
|
|
75
93
|
Path("templates/email"),
|
|
76
94
|
Path("templates/markdown"),
|
|
77
95
|
]
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
if is_dev
|
|
97
|
+
else None
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
server = Granian(
|
|
101
|
+
target="vibetuner.frontend.proxy:app",
|
|
102
|
+
address=host,
|
|
103
|
+
port=port,
|
|
104
|
+
interface=Interfaces.ASGI,
|
|
105
|
+
workers=workers,
|
|
106
|
+
reload=is_dev,
|
|
107
|
+
reload_paths=reload_paths,
|
|
108
|
+
log_level="info",
|
|
109
|
+
log_access=True,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
server.serve()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _run_service(
|
|
116
|
+
mode: Literal["dev", "prod"],
|
|
117
|
+
service: str,
|
|
118
|
+
host: str,
|
|
119
|
+
port: int | None,
|
|
120
|
+
workers: int,
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Dispatch to the appropriate service runner."""
|
|
123
|
+
if service == "worker":
|
|
124
|
+
_run_worker(mode, port or DEFAULT_WORKER_PORT, workers)
|
|
125
|
+
elif service == "frontend":
|
|
126
|
+
_run_frontend(mode, host, port or DEFAULT_FRONTEND_PORT, workers)
|
|
92
127
|
else:
|
|
93
128
|
console.print(f"[red]Error: Unknown service '{service}'[/red]")
|
|
94
129
|
console.print("[yellow]Valid services: 'frontend' or 'worker'[/yellow]")
|
|
95
130
|
raise typer.Exit(code=1)
|
|
96
131
|
|
|
97
132
|
|
|
133
|
+
@run_app.command(name="dev")
|
|
134
|
+
def dev(
|
|
135
|
+
service: Annotated[
|
|
136
|
+
str, typer.Argument(help="Service to run: 'frontend' or 'worker'")
|
|
137
|
+
] = "frontend",
|
|
138
|
+
port: int | None = typer.Option(
|
|
139
|
+
None, help="Port to run on (8000 for frontend, 11111 for worker)"
|
|
140
|
+
),
|
|
141
|
+
auto_port: bool = typer.Option(
|
|
142
|
+
False,
|
|
143
|
+
"--auto-port",
|
|
144
|
+
help="Use deterministic port based on project path (8001-8999)",
|
|
145
|
+
),
|
|
146
|
+
host: str = typer.Option("0.0.0.0", help="Host to bind to (frontend only)"), # noqa: S104
|
|
147
|
+
workers_count: int = typer.Option(
|
|
148
|
+
1, "--workers", help="Number of worker processes"
|
|
149
|
+
),
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Run in development mode with hot reload (frontend or worker)."""
|
|
152
|
+
if port is not None and auto_port:
|
|
153
|
+
console.print("[red]Error: --port and --auto-port are mutually exclusive[/red]")
|
|
154
|
+
raise typer.Exit(code=1)
|
|
155
|
+
|
|
156
|
+
if auto_port:
|
|
157
|
+
port = _compute_auto_port()
|
|
158
|
+
|
|
159
|
+
_run_service("dev", service, host, port, workers_count)
|
|
160
|
+
|
|
161
|
+
|
|
98
162
|
@run_app.command(name="prod")
|
|
99
163
|
def prod(
|
|
100
164
|
service: Annotated[
|
|
@@ -109,52 +173,4 @@ def prod(
|
|
|
109
173
|
),
|
|
110
174
|
) -> None:
|
|
111
175
|
"""Run in production mode (frontend or worker)."""
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if service == "worker":
|
|
115
|
-
# Worker mode
|
|
116
|
-
from streaq.cli import main as streaq_main
|
|
117
|
-
|
|
118
|
-
worker_port = port if port else 11111
|
|
119
|
-
console.print(
|
|
120
|
-
f"[green]Starting worker in prod mode on port {worker_port}[/green]"
|
|
121
|
-
)
|
|
122
|
-
console.print(f"[dim]Workers: {workers_count}[/dim]")
|
|
123
|
-
|
|
124
|
-
# Call streaq programmatically
|
|
125
|
-
streaq_main(
|
|
126
|
-
worker_path="vibetuner.tasks.worker.worker",
|
|
127
|
-
workers=workers_count,
|
|
128
|
-
reload=False,
|
|
129
|
-
verbose=False,
|
|
130
|
-
web=True,
|
|
131
|
-
host="0.0.0.0", # noqa: S104
|
|
132
|
-
port=worker_port,
|
|
133
|
-
)
|
|
134
|
-
elif service == "frontend":
|
|
135
|
-
# Frontend mode
|
|
136
|
-
from granian import Granian
|
|
137
|
-
from granian.constants import Interfaces
|
|
138
|
-
|
|
139
|
-
frontend_port = port if port else 8000
|
|
140
|
-
console.print(
|
|
141
|
-
f"[green]Starting frontend in prod mode on {host}:{frontend_port}[/green]"
|
|
142
|
-
)
|
|
143
|
-
console.print(f"[dim]Workers: {workers_count}[/dim]")
|
|
144
|
-
|
|
145
|
-
server = Granian(
|
|
146
|
-
target="vibetuner.frontend:app",
|
|
147
|
-
address=host,
|
|
148
|
-
port=frontend_port,
|
|
149
|
-
interface=Interfaces.ASGI,
|
|
150
|
-
workers=workers_count,
|
|
151
|
-
reload=False,
|
|
152
|
-
log_level="info",
|
|
153
|
-
log_access=True,
|
|
154
|
-
)
|
|
155
|
-
|
|
156
|
-
server.serve()
|
|
157
|
-
else:
|
|
158
|
-
console.print(f"[red]Error: Unknown service '{service}'[/red]")
|
|
159
|
-
console.print("[yellow]Valid services: 'frontend' or 'worker'[/yellow]")
|
|
160
|
-
raise typer.Exit(code=1)
|
|
176
|
+
_run_service("prod", service, host, port, workers_count)
|
vibetuner/cli/scaffold.py
CHANGED
|
@@ -10,6 +10,7 @@ from rich.console import Console
|
|
|
10
10
|
|
|
11
11
|
console = Console()
|
|
12
12
|
|
|
13
|
+
|
|
13
14
|
scaffold_app = typer.Typer(
|
|
14
15
|
help="Create new projects from the vibetuner template", no_args_is_help=True
|
|
15
16
|
)
|
|
@@ -185,3 +186,9 @@ def update(
|
|
|
185
186
|
except Exception as e:
|
|
186
187
|
console.print(f"[red]Error updating project: {e}[/red]")
|
|
187
188
|
raise typer.Exit(code=1) from None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@scaffold_app.command(name="copy-core-templates", hidden=True)
|
|
192
|
+
def copy_core_templates() -> None:
|
|
193
|
+
"""Deprecated: This command is a no-op kept for backwards compatibility."""
|
|
194
|
+
console.print("[dim]This command is deprecated and does nothing.[/dim]")
|
vibetuner/config.py
CHANGED
|
@@ -2,16 +2,21 @@ import base64
|
|
|
2
2
|
import hashlib
|
|
3
3
|
from datetime import datetime
|
|
4
4
|
from functools import cached_property
|
|
5
|
-
from typing import Annotated
|
|
5
|
+
from typing import Annotated, Literal
|
|
6
6
|
|
|
7
7
|
import yaml
|
|
8
8
|
from pydantic import (
|
|
9
9
|
UUID4,
|
|
10
|
+
AnyUrl,
|
|
10
11
|
Field,
|
|
11
12
|
HttpUrl,
|
|
13
|
+
MariaDBDsn,
|
|
12
14
|
MongoDsn,
|
|
15
|
+
MySQLDsn,
|
|
16
|
+
PostgresDsn,
|
|
13
17
|
RedisDsn,
|
|
14
18
|
SecretStr,
|
|
19
|
+
UrlConstraints,
|
|
15
20
|
computed_field,
|
|
16
21
|
)
|
|
17
22
|
from pydantic_extra_types.language_code import LanguageAlpha2
|
|
@@ -23,6 +28,24 @@ from .paths import config_vars as config_vars_path
|
|
|
23
28
|
from .versioning import version
|
|
24
29
|
|
|
25
30
|
|
|
31
|
+
class SQLiteDsn(AnyUrl):
|
|
32
|
+
"""A type that will accept any SQLite DSN.
|
|
33
|
+
|
|
34
|
+
* User info not required
|
|
35
|
+
* TLD not required
|
|
36
|
+
* Host not required (file-based database)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
_constraints = UrlConstraints(
|
|
40
|
+
allowed_schemes=[
|
|
41
|
+
"sqlite",
|
|
42
|
+
"sqlite+aiosqlite",
|
|
43
|
+
"sqlite+pysqlite",
|
|
44
|
+
],
|
|
45
|
+
host_required=False,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
26
49
|
current_year: int = datetime.now().year
|
|
27
50
|
|
|
28
51
|
|
|
@@ -100,12 +123,15 @@ class CoreConfiguration(BaseSettings):
|
|
|
100
123
|
project: ProjectConfiguration = ProjectConfiguration.from_project_config()
|
|
101
124
|
|
|
102
125
|
debug: bool = False
|
|
126
|
+
environment: Literal["dev", "prod"] = "dev"
|
|
103
127
|
version: str = version
|
|
104
128
|
session_key: SecretStr = SecretStr("ct-!secret-must-change-me")
|
|
129
|
+
debug_access_token: str | None = None
|
|
105
130
|
|
|
106
131
|
# Database and Cache URLs
|
|
107
|
-
mongodb_url: MongoDsn =
|
|
108
|
-
redis_url: RedisDsn =
|
|
132
|
+
mongodb_url: MongoDsn | None = None
|
|
133
|
+
redis_url: RedisDsn | None = None
|
|
134
|
+
database_url: PostgresDsn | MariaDBDsn | MySQLDsn | SQLiteDsn | None = None
|
|
109
135
|
|
|
110
136
|
aws_access_key_id: SecretStr | None = None
|
|
111
137
|
aws_secret_access_key: SecretStr | None = None
|
|
@@ -116,6 +142,18 @@ class CoreConfiguration(BaseSettings):
|
|
|
116
142
|
r2_secret_key: SecretStr | None = None
|
|
117
143
|
r2_default_region: str = "auto"
|
|
118
144
|
|
|
145
|
+
worker_concurrency: int = 16
|
|
146
|
+
|
|
147
|
+
# Proxy configuration for X-Forwarded-For/Proto headers
|
|
148
|
+
# Comma-separated list of trusted proxy IPs/CIDRs (e.g., "127.0.0.1,192.168.1.0/24")
|
|
149
|
+
# SECURITY: Only IPs in this list can set forwarded headers. Use "*" to trust all (NOT recommended for production)
|
|
150
|
+
trusted_proxy_hosts: str = "127.0.0.1"
|
|
151
|
+
|
|
152
|
+
@cached_property
|
|
153
|
+
def trusted_proxy_hosts_list(self) -> list[str]:
|
|
154
|
+
"""Parse trusted proxy hosts into a list for Granian's proxy header wrapper."""
|
|
155
|
+
return [h.strip() for h in self.trusted_proxy_hosts.split(",") if h.strip()]
|
|
156
|
+
|
|
119
157
|
@computed_field
|
|
120
158
|
@cached_property
|
|
121
159
|
def v_hash(self) -> str:
|
|
@@ -128,10 +166,24 @@ class CoreConfiguration(BaseSettings):
|
|
|
128
166
|
|
|
129
167
|
return url_safe_hash
|
|
130
168
|
|
|
169
|
+
@property
|
|
170
|
+
def workers_available(self) -> bool:
|
|
171
|
+
return self.redis_url is not None
|
|
172
|
+
|
|
131
173
|
@cached_property
|
|
132
174
|
def mongo_dbname(self) -> str:
|
|
133
175
|
return self.project.project_slug
|
|
134
176
|
|
|
177
|
+
@cached_property
|
|
178
|
+
def redis_key_prefix(self) -> str:
|
|
179
|
+
"""Returns the Redis key prefix for namespacing all Redis keys by project and environment.
|
|
180
|
+
|
|
181
|
+
Format: "{project_slug}:{env}:" for dev, "{project_slug}:" for prod.
|
|
182
|
+
"""
|
|
183
|
+
if self.environment == "dev":
|
|
184
|
+
return f"{self.project.project_slug}:dev:"
|
|
185
|
+
return f"{self.project.project_slug}:"
|
|
186
|
+
|
|
135
187
|
model_config = SettingsConfigDict(
|
|
136
188
|
case_sensitive=False, extra="ignore", env_file=".env"
|
|
137
189
|
)
|
vibetuner/frontend/__init__.py
CHANGED
vibetuner/frontend/lifespan.py
CHANGED
|
@@ -5,7 +5,8 @@ from fastapi import FastAPI
|
|
|
5
5
|
|
|
6
6
|
from vibetuner.context import ctx
|
|
7
7
|
from vibetuner.logging import logger
|
|
8
|
-
from vibetuner.mongo import
|
|
8
|
+
from vibetuner.mongo import init_mongodb, teardown_mongodb
|
|
9
|
+
from vibetuner.sqlmodel import init_sqlmodel, teardown_sqlmodel
|
|
9
10
|
|
|
10
11
|
from .hotreload import hotreload
|
|
11
12
|
|
|
@@ -16,7 +17,8 @@ async def base_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
|
16
17
|
if ctx.DEBUG:
|
|
17
18
|
await hotreload.startup()
|
|
18
19
|
|
|
19
|
-
await
|
|
20
|
+
await init_mongodb()
|
|
21
|
+
await init_sqlmodel()
|
|
20
22
|
|
|
21
23
|
yield
|
|
22
24
|
|
|
@@ -25,6 +27,9 @@ async def base_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
|
25
27
|
await hotreload.shutdown()
|
|
26
28
|
logger.info("Vibetuner frontend stopped")
|
|
27
29
|
|
|
30
|
+
await teardown_sqlmodel()
|
|
31
|
+
await teardown_mongodb()
|
|
32
|
+
|
|
28
33
|
|
|
29
34
|
try:
|
|
30
35
|
from app.frontend.lifespan import lifespan # ty: ignore
|
vibetuner/frontend/middleware.py
CHANGED
|
@@ -6,7 +6,6 @@ from starlette.middleware.authentication import AuthenticationMiddleware
|
|
|
6
6
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
7
7
|
from starlette.middleware.sessions import SessionMiddleware
|
|
8
8
|
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
|
9
|
-
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
10
9
|
from starlette_babel import (
|
|
11
10
|
LocaleFromCookie,
|
|
12
11
|
LocaleFromQuery,
|
|
@@ -84,29 +83,6 @@ class AdjustLangCookieMiddleware(BaseHTTPMiddleware):
|
|
|
84
83
|
return response
|
|
85
84
|
|
|
86
85
|
|
|
87
|
-
class ForwardedProtocolMiddleware:
|
|
88
|
-
def __init__(self, app: ASGIApp):
|
|
89
|
-
self.app = app
|
|
90
|
-
|
|
91
|
-
# Based on https://github.com/encode/uvicorn/blob/master/uvicorn/middleware/proxy_headers.py
|
|
92
|
-
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
93
|
-
if scope["type"] == "lifespan":
|
|
94
|
-
return await self.app(scope, receive, send)
|
|
95
|
-
|
|
96
|
-
headers = dict(scope["headers"])
|
|
97
|
-
|
|
98
|
-
if b"x-forwarded-proto" in headers:
|
|
99
|
-
x_forwarded_proto = headers[b"x-forwarded-proto"].decode("latin1").strip()
|
|
100
|
-
|
|
101
|
-
if x_forwarded_proto in {"http", "https", "ws", "wss"}:
|
|
102
|
-
if scope["type"] == "websocket":
|
|
103
|
-
scope["scheme"] = x_forwarded_proto.replace("http", "ws")
|
|
104
|
-
else:
|
|
105
|
-
scope["scheme"] = x_forwarded_proto
|
|
106
|
-
|
|
107
|
-
return await self.app(scope, receive, send)
|
|
108
|
-
|
|
109
|
-
|
|
110
86
|
class AuthBackend(AuthenticationBackend):
|
|
111
87
|
async def authenticate(
|
|
112
88
|
self,
|
|
@@ -128,7 +104,6 @@ class AuthBackend(AuthenticationBackend):
|
|
|
128
104
|
|
|
129
105
|
middlewares: list[Middleware] = [
|
|
130
106
|
Middleware(TrustedHostMiddleware),
|
|
131
|
-
Middleware(ForwardedProtocolMiddleware),
|
|
132
107
|
Middleware(HtmxMiddleware),
|
|
133
108
|
Middleware(SessionMiddleware, secret_key=settings.session_key.get_secret_value()),
|
|
134
109
|
Middleware(
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# ABOUTME: ASGI app wrapper that processes X-Forwarded-For and X-Forwarded-Proto headers
|
|
2
|
+
# ABOUTME: Uses Granian's proxy header support to extract real client IP and protocol
|
|
3
|
+
from granian.utils.proxies import wrap_asgi_with_proxy_headers
|
|
4
|
+
|
|
5
|
+
from vibetuner.config import settings
|
|
6
|
+
from vibetuner.frontend import app as _app
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# Wrap the FastAPI app with Granian's proxy header processor
|
|
10
|
+
# This enables real client IP detection when behind reverse proxies (nginx, Cloudflare, etc.)
|
|
11
|
+
# Only headers from trusted_proxy_hosts will be processed to prevent IP spoofing
|
|
12
|
+
app = wrap_asgi_with_proxy_headers(
|
|
13
|
+
_app, trusted_hosts=settings.trusted_proxy_hosts_list
|
|
14
|
+
)
|