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.

@@ -3,13 +3,13 @@ from fastapi import (
3
3
  Depends,
4
4
  HTTPException,
5
5
  Request,
6
- Response,
7
6
  )
8
7
  from fastapi.responses import (
9
8
  HTMLResponse,
10
9
  RedirectResponse,
11
10
  )
12
11
 
12
+ from vibetuner.config import settings
13
13
  from vibetuner.context import ctx
14
14
  from vibetuner.models import UserModel
15
15
  from vibetuner.models.registry import get_all_models
@@ -18,32 +18,41 @@ from ..deps import MAGIC_COOKIE_NAME
18
18
  from ..templates import render_template
19
19
 
20
20
 
21
- def check_debug_access(request: Request, prod: str | None = None):
21
+ def check_debug_access(request: Request):
22
22
  """Check if debug routes should be accessible."""
23
23
  # Always allow in development mode
24
24
  if ctx.DEBUG:
25
25
  return True
26
26
 
27
- # In production, require prod=1 parameter
28
- if prod == "1":
29
- return True
27
+ # In production, require magic cookie
28
+ if MAGIC_COOKIE_NAME not in request.cookies:
29
+ raise HTTPException(status_code=404, detail="Not found")
30
+ if request.cookies[MAGIC_COOKIE_NAME] != "granted":
31
+ raise HTTPException(status_code=404, detail="Not found")
30
32
 
31
- # Deny access
32
- raise HTTPException(status_code=404, detail="Not found")
33
+ return True
33
34
 
34
35
 
35
- router = APIRouter(prefix="/debug", dependencies=[Depends(check_debug_access)])
36
+ # Unprotected router for token-based debug access
37
+ auth_router = APIRouter()
36
38
 
37
39
 
38
- @router.get("/", response_class=HTMLResponse)
39
- def debug_index(request: Request):
40
- return render_template("debug/index.html.jinja", request)
40
+ @auth_router.get("/_unlock-debug")
41
+ def unlock_debug_access(token: str | None = None):
42
+ """Grant debug access by setting the magic cookie.
41
43
 
44
+ In DEBUG mode, no token is required.
45
+ In production, the token must match DEBUG_ACCESS_TOKEN.
46
+ If DEBUG_ACCESS_TOKEN is not configured, debug access is disabled in production.
47
+ """
48
+ if not ctx.DEBUG:
49
+ # In production, validate token
50
+ if settings.debug_access_token is None:
51
+ raise HTTPException(status_code=404, detail="Not found")
52
+ if token is None or token != settings.debug_access_token:
53
+ raise HTTPException(status_code=404, detail="Not found")
42
54
 
43
- @router.get("/magic")
44
- def set_magic_cookie(response: Response):
45
- """Set the magic access cookie."""
46
- response = RedirectResponse(url="/", status_code=302)
55
+ response = RedirectResponse(url="/debug", status_code=302)
47
56
  response.set_cookie(
48
57
  key=MAGIC_COOKIE_NAME,
49
58
  value="granted",
@@ -55,14 +64,23 @@ def set_magic_cookie(response: Response):
55
64
  return response
56
65
 
57
66
 
58
- @router.get("/no-magic")
59
- def remove_magic_cookie(response: Response):
60
- """Remove the magic access cookie."""
67
+ @auth_router.get("/_lock-debug")
68
+ def lock_debug_access():
69
+ """Revoke debug access by removing the magic cookie."""
61
70
  response = RedirectResponse(url="/", status_code=302)
62
71
  response.delete_cookie(key=MAGIC_COOKIE_NAME)
63
72
  return response
64
73
 
65
74
 
75
+ # Protected router for debug endpoints requiring cookie auth
76
+ router = APIRouter(prefix="/debug", dependencies=[Depends(check_debug_access)])
77
+
78
+
79
+ @router.get("/", response_class=HTMLResponse)
80
+ def debug_index(request: Request):
81
+ return render_template("debug/index.html.jinja", request)
82
+
83
+
66
84
  @router.get("/version", response_class=HTMLResponse)
67
85
  def debug_version(request: Request):
68
86
  return render_template("debug/version.html.jinja", request)
@@ -136,20 +154,11 @@ def _is_beanie_link_schema(option: dict) -> bool:
136
154
 
137
155
  def _infer_link_target_from_field_name(field_name: str) -> str:
138
156
  """Infer the target model type from field name patterns."""
139
- # Common patterns for field names that reference other models
157
+ # Core vibetuner models
140
158
  patterns = {
141
159
  "oauth_accounts": "OAuthAccountModel",
142
- "accounts": "AccountModel",
143
160
  "users": "UserModel",
144
161
  "user": "UserModel",
145
- "stations": "StationModel",
146
- "station": "StationModel",
147
- "rundowns": "RundownModel",
148
- "rundown": "RundownModel",
149
- "fillers": "FillerModel",
150
- "filler": "FillerModel",
151
- "voices": "VoiceModel",
152
- "voice": "VoiceModel",
153
162
  "blobs": "BlobModel",
154
163
  "blob": "BlobModel",
155
164
  }
@@ -374,10 +383,13 @@ async def debug_users(request: Request):
374
383
  )
375
384
 
376
385
 
386
+ # The following endpoints are restricted to DEBUG mode only (no production access).
387
+ # These are dangerous operations that could compromise security if allowed in production.
388
+
389
+
377
390
  @router.post("/impersonate/{user_id}")
378
391
  async def debug_impersonate_user(request: Request, user_id: str):
379
392
  """Impersonate a user by setting their ID in the session."""
380
- # Double check debug mode for security
381
393
  if not ctx.DEBUG:
382
394
  raise HTTPException(status_code=404, detail="Not found")
383
395
 
@@ -395,7 +407,6 @@ async def debug_impersonate_user(request: Request, user_id: str):
395
407
  @router.post("/stop-impersonation")
396
408
  async def debug_stop_impersonation(request: Request):
397
409
  """Stop impersonating and clear user session."""
398
- # Double check debug mode for security
399
410
  if not ctx.DEBUG:
400
411
  raise HTTPException(status_code=404, detail="Not found")
401
412
 
@@ -406,7 +417,6 @@ async def debug_stop_impersonation(request: Request):
406
417
  @router.get("/clear-session")
407
418
  async def debug_clear_session(request: Request):
408
419
  """Clear all session data to fix corrupted sessions."""
409
- # Double check debug mode for security
410
420
  if not ctx.DEBUG:
411
421
  raise HTTPException(status_code=404, detail="Not found")
412
422
 
@@ -17,9 +17,40 @@ from .hotreload import hotreload
17
17
 
18
18
  __all__ = [
19
19
  "render_static_template",
20
+ "render_template",
21
+ "render_template_string",
22
+ "register_filter",
20
23
  ]
21
24
 
22
25
 
26
+ _filter_registry: dict[str, Any] = {}
27
+
28
+
29
+ def register_filter(name: str | None = None):
30
+ """Decorator to register a custom Jinja2 filter.
31
+
32
+ Args:
33
+ name: Optional custom name for the filter. If not provided,
34
+ uses the function name.
35
+
36
+ Usage:
37
+ @register_filter()
38
+ def my_filter(value):
39
+ return value.upper()
40
+
41
+ @register_filter("custom_name")
42
+ def another_filter(value):
43
+ return value.lower()
44
+ """
45
+
46
+ def decorator(func):
47
+ filter_name = name or func.__name__
48
+ _filter_registry[filter_name] = func
49
+ return func
50
+
51
+ return decorator
52
+
53
+
23
54
  def timeago(dt):
24
55
  """Converts a datetime object to a human-readable string representing the time elapsed since the given datetime.
25
56
 
@@ -158,6 +189,38 @@ def render_template(
158
189
  return templates.TemplateResponse(template, merged_ctx, **kwargs)
159
190
 
160
191
 
192
+ def render_template_string(
193
+ template: str,
194
+ request: Request,
195
+ ctx: dict[str, Any] | None = None,
196
+ ) -> str:
197
+ """Render a template to a string instead of HTMLResponse.
198
+
199
+ Useful for Server-Sent Events (SSE), AJAX responses, or any case where you need
200
+ the rendered HTML as a string rather than a full HTTP response.
201
+
202
+ Args:
203
+ template: Path to template file (e.g., "admin/partials/episode.html.jinja")
204
+ request: FastAPI Request object
205
+ ctx: Optional context dictionary to pass to template
206
+
207
+ Returns:
208
+ str: Rendered template as a string
209
+
210
+ Example:
211
+ html = render_template_string(
212
+ "admin/partials/episode_article.html.jinja",
213
+ request,
214
+ {"episode": episode}
215
+ )
216
+ """
217
+ ctx = ctx or {}
218
+ merged_ctx = {**data_ctx.model_dump(), "request": request, **ctx}
219
+
220
+ template_obj = templates.get_template(template)
221
+ return template_obj.render(merged_ctx)
222
+
223
+
161
224
  # Global Vars
162
225
  jinja_env.globals.update({"DEBUG": data_ctx.DEBUG})
163
226
  jinja_env.globals.update({"hotreload": hotreload})
@@ -171,4 +234,22 @@ jinja_env.filters["format_datetime"] = format_datetime
171
234
  jinja_env.filters["format_duration"] = format_duration
172
235
  jinja_env.filters["duration"] = format_duration
173
236
 
237
+ # Import user-defined filters to trigger registration
238
+ try:
239
+ import app.frontend.templates as _app_templates # type: ignore[import-not-found] # noqa: F401
240
+ except ModuleNotFoundError:
241
+ # Silent pass - templates module is optional
242
+ pass
243
+ except ImportError as e:
244
+ from vibetuner.logging import logger
245
+
246
+ logger.warning(
247
+ f"Failed to import app.frontend.templates: {e}. Custom filters will not be available."
248
+ )
249
+
250
+ # Apply all registered custom filters
251
+ for filter_name, filter_func in _filter_registry.items():
252
+ jinja_env.filters[filter_name] = filter_func
253
+
254
+ # Configure Jinja environment after all filters are registered
174
255
  configure_jinja_env(jinja_env)
vibetuner/mongo.py CHANGED
@@ -1,6 +1,8 @@
1
1
  from importlib import import_module
2
+ from typing import Optional
2
3
 
3
4
  from beanie import init_beanie
5
+ from deprecated import deprecated
4
6
  from pymongo import AsyncMongoClient
5
7
 
6
8
  from vibetuner.config import settings
@@ -8,26 +10,64 @@ from vibetuner.logging import logger
8
10
  from vibetuner.models.registry import get_all_models
9
11
 
10
12
 
11
- async def init_models() -> None:
12
- """Initialize MongoDB connection and register all Beanie models."""
13
+ # Global singleton, created lazily
14
+ mongo_client: Optional[AsyncMongoClient] = None
15
+
16
+
17
+ def _ensure_client() -> None:
18
+ """
19
+ Lazily create the global MongoDB client if mongodb_url is configured.
20
+ Safe to call many times.
21
+ """
22
+ global mongo_client
23
+
24
+ if settings.mongodb_url is None:
25
+ logger.warning("MongoDB URL is not configured. Mongo engine disabled.")
26
+ return
27
+
28
+ if mongo_client is None:
29
+ mongo_client = AsyncMongoClient(
30
+ host=str(settings.mongodb_url),
31
+ compressors=["zstd"],
32
+ )
33
+ logger.debug("MongoDB client created.")
34
+
35
+
36
+ async def init_mongodb() -> None:
37
+ """Initialize MongoDB and register Beanie models."""
38
+ _ensure_client()
39
+
40
+ if mongo_client is None:
41
+ # Nothing to do; URL missing
42
+ return
13
43
 
14
- # Try to import user models to trigger their registration
44
+ # Import user models so they register themselves
15
45
  try:
16
46
  import_module("app.models")
17
47
  except ModuleNotFoundError:
18
- # Silent pass for missing app.models module (expected in some projects)
19
- pass
48
+ logger.debug("app.models not found; skipping user model import.")
20
49
  except ImportError as e:
21
- # Log warning for any import error (including syntax errors, missing dependencies, etc.)
22
- logger.warning(
23
- f"Failed to import app.models: {e}. User models will not be registered."
24
- )
25
-
26
- client: AsyncMongoClient = AsyncMongoClient(
27
- host=str(settings.mongodb_url),
28
- compressors=["zstd"],
29
- )
50
+ logger.warning(f"Failed to import app.models: {e}. User models may be missing.")
30
51
 
31
52
  await init_beanie(
32
- database=client[settings.mongo_dbname], document_models=get_all_models()
53
+ database=mongo_client[settings.mongo_dbname],
54
+ document_models=get_all_models(),
33
55
  )
56
+ logger.info("MongoDB + Beanie initialized successfully.")
57
+
58
+
59
+ async def teardown_mongodb() -> None:
60
+ """Dispose the MongoDB client."""
61
+ global mongo_client
62
+
63
+ if mongo_client is not None:
64
+ await mongo_client.close()
65
+ mongo_client = None
66
+ logger.info("MongoDB client closed.")
67
+ else:
68
+ logger.debug("MongoDB client was never initialized; nothing to tear down.")
69
+
70
+
71
+ @deprecated(reason="Use init_mongodb() instead")
72
+ async def init_models() -> None:
73
+ await init_mongodb()
vibetuner/paths.py CHANGED
@@ -33,24 +33,27 @@ def create_core_templates_symlink(target: Path) -> None:
33
33
  logger.error(f"Cannot create symlink: {e}")
34
34
  return
35
35
 
36
- # Case 1: target does not existcreate symlink
36
+ # Case 1: target is already a symlink check if it needs updating
37
+ if target.is_symlink():
38
+ if target.resolve() != source:
39
+ target.unlink()
40
+ target.symlink_to(source, target_is_directory=True)
41
+ logger.info(f"Updated symlink '{target}' → '{source}'")
42
+ else:
43
+ logger.debug(f"Symlink '{target}' already points to '{source}'")
44
+ return
45
+
46
+ # Case 2: target does not exist → create symlink
37
47
  if not target.exists():
38
48
  target.symlink_to(source, target_is_directory=True)
39
49
  logger.info(f"Created symlink '{target}' → '{source}'")
40
50
  return
41
51
 
42
- # Case 2: exists but is not a symlink → error
43
- if not target.is_symlink():
44
- logger.error(f"Cannot create symlink: '{target}' exists and is not a symlink.")
45
- raise FileExistsError(
46
- f"Cannot create symlink: '{target}' exists and is not a symlink."
47
- )
48
-
49
- # Case 3: is a symlink but points somewhere else → update it
50
- if target.resolve() != source:
51
- target.unlink()
52
- target.symlink_to(source, target_is_directory=True)
53
- logger.info(f"Updated symlink '{target}' → '{source}'")
52
+ # Case 3: exists but is not a symlink → error
53
+ logger.error(f"Cannot create symlink: '{target}' exists and is not a symlink.")
54
+ raise FileExistsError(
55
+ f"Cannot create symlink: '{target}' exists and is not a symlink."
56
+ )
54
57
 
55
58
 
56
59
  # Package templates always available
@@ -6,18 +6,13 @@ To extend blob functionality, create wrapper services in the parent services dir
6
6
 
7
7
  import mimetypes
8
8
  from pathlib import Path
9
- from typing import Literal
10
9
 
11
10
  import aioboto3
12
- from aiobotocore.config import AioConfig
13
11
 
14
12
  from vibetuner.config import settings
15
13
  from vibetuner.models import BlobModel
16
14
  from vibetuner.models.blob import BlobStatus
17
-
18
-
19
- S3_SERVICE_NAME: Literal["s3"] = "s3"
20
- DEFAULT_CONTENT_TYPE: str = "application/octet-stream"
15
+ from vibetuner.services.s3_storage import DEFAULT_CONTENT_TYPE, S3StorageService
21
16
 
22
17
 
23
18
  class BlobService:
@@ -34,25 +29,22 @@ class BlobService:
34
29
  raise ValueError(
35
30
  "R2 bucket endpoint URL, access key, and secret key must be set in settings."
36
31
  )
37
- self.session = session or aioboto3.Session(
38
- aws_access_key_id=settings.r2_access_key.get_secret_value(),
39
- aws_secret_access_key=settings.r2_secret_key.get_secret_value(),
40
- region_name=settings.r2_default_region,
41
- )
42
- self.endpoint_url = str(settings.r2_bucket_endpoint_url)
43
- self.config = AioConfig(
44
- request_checksum_calculation="when_required",
45
- response_checksum_validation="when_required",
46
- )
47
32
 
48
- if not default_bucket:
49
- if settings.r2_default_bucket_name is None:
50
- raise ValueError(
51
- "Default bucket name must be provided either in settings or as an argument."
52
- )
53
- self.default_bucket = settings.r2_default_bucket_name
54
- else:
55
- self.default_bucket = default_bucket
33
+ bucket = default_bucket or settings.r2_default_bucket_name
34
+ if bucket is None:
35
+ raise ValueError(
36
+ "Default bucket name must be provided either in settings or as an argument."
37
+ )
38
+
39
+ self.storage = S3StorageService(
40
+ endpoint_url=str(settings.r2_bucket_endpoint_url),
41
+ access_key=settings.r2_access_key.get_secret_value(),
42
+ secret_key=settings.r2_secret_key.get_secret_value(),
43
+ region=settings.r2_default_region,
44
+ default_bucket=bucket,
45
+ session=session,
46
+ )
47
+ self.default_bucket = bucket
56
48
 
57
49
  async def put_object(
58
50
  self,
@@ -80,17 +72,12 @@ class BlobService:
80
72
  raise ValueError("Blob ID must be set before uploading to R2.")
81
73
 
82
74
  try:
83
- async with self.session.client(
84
- service_name=S3_SERVICE_NAME,
85
- endpoint_url=self.endpoint_url,
86
- config=self.config,
87
- ) as s3_client:
88
- await s3_client.put_object(
89
- Bucket=bucket,
90
- Key=blob.full_path,
91
- Body=body,
92
- ContentType=content_type,
93
- )
75
+ await self.storage.put_object(
76
+ key=blob.full_path,
77
+ body=body,
78
+ content_type=content_type,
79
+ bucket=bucket,
80
+ )
94
81
  blob.status = BlobStatus.UPLOADED
95
82
  except Exception:
96
83
  blob.status = BlobStatus.ERROR
@@ -144,16 +131,10 @@ class BlobService:
144
131
  if not blob:
145
132
  raise ValueError(f"Blob not found: {key}")
146
133
 
147
- async with self.session.client(
148
- service_name=S3_SERVICE_NAME,
149
- endpoint_url=self.endpoint_url,
150
- config=self.config,
151
- ) as s3_client:
152
- response = await s3_client.get_object(
153
- Bucket=blob.bucket,
154
- Key=blob.full_path,
155
- )
156
- return await response["Body"].read()
134
+ return await self.storage.get_object(
135
+ key=blob.full_path,
136
+ bucket=blob.bucket,
137
+ )
157
138
 
158
139
  async def delete_object(self, key: str) -> None:
159
140
  """Delete an object from the R2 bucket"""
@@ -172,4 +153,9 @@ class BlobService:
172
153
  if not blob:
173
154
  return False
174
155
 
156
+ if check_bucket:
157
+ return await self.storage.object_exists(
158
+ key=blob.full_path, bucket=blob.bucket
159
+ )
160
+
175
161
  return True
@@ -6,7 +6,7 @@ To extend email functionality, create wrapper services in the parent services di
6
6
 
7
7
  from typing import Literal
8
8
 
9
- import boto3
9
+ import aioboto3
10
10
 
11
11
  from vibetuner.config import settings
12
12
 
@@ -17,11 +17,9 @@ SES_SERVICE_NAME: Literal["ses"] = "ses"
17
17
  class SESEmailService:
18
18
  def __init__(
19
19
  self,
20
- ses_client=None,
21
20
  from_email: str | None = None,
22
21
  ) -> None:
23
- self.ses_client = ses_client or boto3.client(
24
- service_name=SES_SERVICE_NAME,
22
+ self.session = aioboto3.Session(
25
23
  region_name=settings.project.aws_default_region,
26
24
  aws_access_key_id=settings.aws_access_key_id.get_secret_value()
27
25
  if settings.aws_access_key_id
@@ -36,15 +34,16 @@ class SESEmailService:
36
34
  self, to_address: str, subject: str, html_body: str, text_body: str
37
35
  ):
38
36
  """Send email using Amazon SES"""
39
- response = self.ses_client.send_email(
40
- Source=self.from_email,
41
- Destination={"ToAddresses": [to_address]},
42
- Message={
43
- "Subject": {"Data": subject, "Charset": "UTF-8"},
44
- "Body": {
45
- "Html": {"Data": html_body, "Charset": "UTF-8"},
46
- "Text": {"Data": text_body, "Charset": "UTF-8"},
37
+ async with self.session.client(SES_SERVICE_NAME) as ses_client:
38
+ response = await ses_client.send_email(
39
+ Source=self.from_email,
40
+ Destination={"ToAddresses": [to_address]},
41
+ Message={
42
+ "Subject": {"Data": subject, "Charset": "UTF-8"},
43
+ "Body": {
44
+ "Html": {"Data": html_body, "Charset": "UTF-8"},
45
+ "Text": {"Data": text_body, "Charset": "UTF-8"},
46
+ },
47
47
  },
48
- },
49
- )
50
- return response
48
+ )
49
+ return response