vibetuner 2.6.1__py3-none-any.whl → 2.7.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.

Files changed (85) hide show
  1. vibetuner/__init__.py +2 -0
  2. vibetuner/__main__.py +4 -0
  3. vibetuner/cli/__init__.py +68 -0
  4. vibetuner/cli/run.py +161 -0
  5. vibetuner/config.py +128 -0
  6. vibetuner/context.py +25 -0
  7. vibetuner/frontend/AGENTS.md +113 -0
  8. vibetuner/frontend/CLAUDE.md +113 -0
  9. vibetuner/frontend/__init__.py +94 -0
  10. vibetuner/frontend/context.py +10 -0
  11. vibetuner/frontend/deps.py +41 -0
  12. vibetuner/frontend/email.py +45 -0
  13. vibetuner/frontend/hotreload.py +13 -0
  14. vibetuner/frontend/lifespan.py +26 -0
  15. vibetuner/frontend/middleware.py +151 -0
  16. vibetuner/frontend/oauth.py +196 -0
  17. vibetuner/frontend/routes/__init__.py +12 -0
  18. vibetuner/frontend/routes/auth.py +150 -0
  19. vibetuner/frontend/routes/debug.py +414 -0
  20. vibetuner/frontend/routes/health.py +33 -0
  21. vibetuner/frontend/routes/language.py +43 -0
  22. vibetuner/frontend/routes/meta.py +55 -0
  23. vibetuner/frontend/routes/user.py +94 -0
  24. vibetuner/frontend/templates.py +176 -0
  25. vibetuner/logging.py +87 -0
  26. vibetuner/models/AGENTS.md +165 -0
  27. vibetuner/models/CLAUDE.md +165 -0
  28. vibetuner/models/__init__.py +14 -0
  29. vibetuner/models/blob.py +89 -0
  30. vibetuner/models/email_verification.py +84 -0
  31. vibetuner/models/mixins.py +76 -0
  32. vibetuner/models/oauth.py +57 -0
  33. vibetuner/models/registry.py +15 -0
  34. vibetuner/models/types.py +16 -0
  35. vibetuner/models/user.py +91 -0
  36. vibetuner/mongo.py +18 -0
  37. vibetuner/paths.py +112 -0
  38. vibetuner/services/AGENTS.md +104 -0
  39. vibetuner/services/CLAUDE.md +104 -0
  40. vibetuner/services/__init__.py +0 -0
  41. vibetuner/services/blob.py +175 -0
  42. vibetuner/services/email.py +50 -0
  43. vibetuner/tasks/AGENTS.md +98 -0
  44. vibetuner/tasks/CLAUDE.md +98 -0
  45. vibetuner/tasks/__init__.py +2 -0
  46. vibetuner/tasks/context.py +34 -0
  47. vibetuner/tasks/worker.py +18 -0
  48. vibetuner/templates/email/AGENTS.md +48 -0
  49. vibetuner/templates/email/CLAUDE.md +48 -0
  50. vibetuner/templates/email/default/magic_link.html.jinja +16 -0
  51. vibetuner/templates/email/default/magic_link.txt.jinja +5 -0
  52. vibetuner/templates/frontend/AGENTS.md +74 -0
  53. vibetuner/templates/frontend/CLAUDE.md +74 -0
  54. vibetuner/templates/frontend/base/favicons.html.jinja +1 -0
  55. vibetuner/templates/frontend/base/footer.html.jinja +3 -0
  56. vibetuner/templates/frontend/base/header.html.jinja +0 -0
  57. vibetuner/templates/frontend/base/opengraph.html.jinja +7 -0
  58. vibetuner/templates/frontend/base/skeleton.html.jinja +42 -0
  59. vibetuner/templates/frontend/debug/collections.html.jinja +103 -0
  60. vibetuner/templates/frontend/debug/components/debug_nav.html.jinja +55 -0
  61. vibetuner/templates/frontend/debug/index.html.jinja +83 -0
  62. vibetuner/templates/frontend/debug/info.html.jinja +256 -0
  63. vibetuner/templates/frontend/debug/users.html.jinja +137 -0
  64. vibetuner/templates/frontend/debug/version.html.jinja +53 -0
  65. vibetuner/templates/frontend/email/magic_link.txt.jinja +5 -0
  66. vibetuner/templates/frontend/email_sent.html.jinja +82 -0
  67. vibetuner/templates/frontend/index.html.jinja +19 -0
  68. vibetuner/templates/frontend/lang/select.html.jinja +4 -0
  69. vibetuner/templates/frontend/login.html.jinja +84 -0
  70. vibetuner/templates/frontend/meta/browserconfig.xml.jinja +10 -0
  71. vibetuner/templates/frontend/meta/robots.txt.jinja +3 -0
  72. vibetuner/templates/frontend/meta/site.webmanifest.jinja +7 -0
  73. vibetuner/templates/frontend/meta/sitemap.xml.jinja +6 -0
  74. vibetuner/templates/frontend/user/edit.html.jinja +85 -0
  75. vibetuner/templates/frontend/user/profile.html.jinja +156 -0
  76. vibetuner/templates/markdown/.placeholder +0 -0
  77. vibetuner/templates/markdown/AGENTS.md +29 -0
  78. vibetuner/templates/markdown/CLAUDE.md +29 -0
  79. vibetuner/templates.py +152 -0
  80. vibetuner/time.py +57 -0
  81. vibetuner/versioning.py +8 -0
  82. {vibetuner-2.6.1.dist-info → vibetuner-2.7.1.dist-info}/METADATA +2 -1
  83. vibetuner-2.7.1.dist-info/RECORD +84 -0
  84. vibetuner-2.6.1.dist-info/RECORD +0 -4
  85. {vibetuner-2.6.1.dist-info → vibetuner-2.7.1.dist-info}/WHEEL +0 -0
@@ -0,0 +1,150 @@
1
+ from typing import Annotated
2
+
3
+ from fastapi import (
4
+ APIRouter,
5
+ BackgroundTasks,
6
+ Depends,
7
+ Form,
8
+ HTTPException,
9
+ Request,
10
+ )
11
+ from fastapi.responses import RedirectResponse
12
+ from pydantic import EmailStr
13
+ from starlette.responses import HTMLResponse
14
+
15
+ from vibetuner.models import EmailVerificationTokenModel, UserModel
16
+ from vibetuner.services.email import SESEmailService
17
+
18
+ from ..email import send_magic_link_email
19
+ from ..oauth import (
20
+ _create_auth_handler,
21
+ _create_auth_login_handler,
22
+ get_oauth_providers,
23
+ )
24
+ from ..templates import render_template
25
+ from . import get_homepage_url
26
+
27
+
28
+ def get_ses_service() -> SESEmailService:
29
+ return SESEmailService()
30
+
31
+
32
+ def logout_user(request: Request):
33
+ request.session.pop("user", None)
34
+
35
+
36
+ router = APIRouter(prefix="/auth")
37
+
38
+
39
+ @router.get(
40
+ "/logout",
41
+ dependencies=[Depends(logout_user)],
42
+ response_class=RedirectResponse,
43
+ status_code=307,
44
+ )
45
+ async def auth_logout(request: Request):
46
+ return get_homepage_url(request)
47
+
48
+
49
+ @router.get("/login", response_model=None)
50
+ async def auth_login(
51
+ request: Request,
52
+ next: str | None = None,
53
+ ) -> RedirectResponse | HTMLResponse:
54
+ """Display unified login page with all available options"""
55
+ if request.user.is_authenticated:
56
+ # If user is already authenticated, redirect to homepage
57
+ return RedirectResponse(url=get_homepage_url(request), status_code=302)
58
+
59
+ oauth_providers = get_oauth_providers()
60
+ return render_template(
61
+ "login.html.jinja",
62
+ request=request,
63
+ ctx={
64
+ "providers": oauth_providers,
65
+ "next": next,
66
+ "has_oauth": bool(oauth_providers),
67
+ "has_email": True,
68
+ },
69
+ )
70
+
71
+
72
+ @router.post("/magic-link-login", response_model=None)
73
+ async def send_magic_link(
74
+ request: Request,
75
+ ses_service: Annotated[SESEmailService, Depends(get_ses_service)],
76
+ background_tasks: BackgroundTasks,
77
+ email: Annotated[EmailStr, Form()],
78
+ next: Annotated[str | None, Form()] = None,
79
+ ) -> HTMLResponse:
80
+ """Handle email magic link login form submission"""
81
+
82
+ # Create verification token
83
+ verification_token = await EmailVerificationTokenModel.create_token(email)
84
+
85
+ # Build login URL
86
+ login_url = request.url_for("email_verify", token=verification_token.token)
87
+ if next:
88
+ login_url = login_url.include_query_params(next=next)
89
+
90
+ background_tasks.add_task(
91
+ send_magic_link_email,
92
+ ses_service=ses_service,
93
+ lang=request.state.language,
94
+ to_address=email,
95
+ login_url=str(login_url),
96
+ )
97
+
98
+ return render_template(
99
+ "email_sent.html.jinja", request=request, ctx={"email": email, "next": next}
100
+ )
101
+
102
+
103
+ @router.get(
104
+ "/email-verify/{token}",
105
+ response_class=RedirectResponse,
106
+ status_code=302,
107
+ response_model=None,
108
+ )
109
+ async def email_verify(
110
+ request: Request,
111
+ token: str,
112
+ next: str | None = None,
113
+ ) -> str:
114
+ """Verify email token and log in user"""
115
+ # Verify token
116
+ verification_token = await EmailVerificationTokenModel.verify_token(token)
117
+ if not verification_token:
118
+ raise HTTPException(status_code=400, detail="Invalid or expired token")
119
+
120
+ # Get or create user
121
+ user = await UserModel.get_by_email(verification_token.email)
122
+ if not user:
123
+ # Create new user
124
+ user = UserModel(
125
+ email=verification_token.email,
126
+ # Use email prefix as default name
127
+ name=verification_token.email.split("@")[0],
128
+ )
129
+ await user.insert()
130
+
131
+ # Set session
132
+ request.session["user"] = user.session_dict
133
+
134
+ # Redirect
135
+ return next or get_homepage_url(request)
136
+
137
+
138
+ for provider in get_oauth_providers():
139
+ router.get(
140
+ f"/provider/{provider}",
141
+ response_class=RedirectResponse,
142
+ name=f"auth_with_{provider}",
143
+ response_model=None,
144
+ )(_create_auth_handler(provider))
145
+
146
+ router.get(
147
+ f"/login/provider/{provider}",
148
+ name=f"login_with_{provider}",
149
+ response_model=None,
150
+ )(_create_auth_login_handler(provider))
@@ -0,0 +1,414 @@
1
+ from fastapi import (
2
+ APIRouter,
3
+ Depends,
4
+ HTTPException,
5
+ Request,
6
+ Response,
7
+ )
8
+ from fastapi.responses import (
9
+ HTMLResponse,
10
+ RedirectResponse,
11
+ )
12
+
13
+ from vibetuner.models import UserModel
14
+ from vibetuner.models.registry import get_all_models
15
+
16
+ from ..context import ctx
17
+ from ..deps import MAGIC_COOKIE_NAME
18
+ from ..templates import render_template
19
+
20
+
21
+ def check_debug_access(request: Request, prod: str | None = None):
22
+ """Check if debug routes should be accessible."""
23
+ # Always allow in development mode
24
+ if ctx.DEBUG:
25
+ return True
26
+
27
+ # In production, require prod=1 parameter
28
+ if prod == "1":
29
+ return True
30
+
31
+ # Deny access
32
+ raise HTTPException(status_code=404, detail="Not found")
33
+
34
+
35
+ router = APIRouter(prefix="/debug", dependencies=[Depends(check_debug_access)])
36
+
37
+
38
+ @router.get("/", response_class=HTMLResponse)
39
+ def debug_index(request: Request):
40
+ return render_template("debug/index.html.jinja", request)
41
+
42
+
43
+ @router.get("/magic")
44
+ def set_magic_cookie(response: Response):
45
+ """Set the magic access cookie."""
46
+ response = RedirectResponse(url="/", status_code=302)
47
+ response.set_cookie(
48
+ key=MAGIC_COOKIE_NAME,
49
+ value="granted",
50
+ httponly=True,
51
+ secure=not ctx.DEBUG, # Only secure in production
52
+ samesite="lax",
53
+ max_age=86400 * 30, # 30 days
54
+ )
55
+ return response
56
+
57
+
58
+ @router.get("/no-magic")
59
+ def remove_magic_cookie(response: Response):
60
+ """Remove the magic access cookie."""
61
+ response = RedirectResponse(url="/", status_code=302)
62
+ response.delete_cookie(key=MAGIC_COOKIE_NAME)
63
+ return response
64
+
65
+
66
+ @router.get("/version", response_class=HTMLResponse)
67
+ def debug_version(request: Request):
68
+ return render_template("debug/version.html.jinja", request)
69
+
70
+
71
+ @router.get("/info", response_class=HTMLResponse)
72
+ def debug_info(request: Request):
73
+ # Get cookies from request
74
+ cookies = dict(request.cookies)
75
+
76
+ # Get language from request state
77
+ language = getattr(request.state, "language", "Not set")
78
+
79
+ return render_template(
80
+ "debug/info.html.jinja", request, {"cookies": cookies, "language": language}
81
+ )
82
+
83
+
84
+ def _extract_ref_name(ref: str) -> str:
85
+ """Extract type name from JSON schema $ref."""
86
+ return ref.split("/")[-1]
87
+
88
+
89
+ def _parse_array_type(field_info: dict, field_name: str = "") -> str:
90
+ """Parse array field type from JSON schema."""
91
+ if "items" not in field_info:
92
+ return "array[object]"
93
+
94
+ items = field_info["items"]
95
+ items_type = items.get("type", "")
96
+
97
+ # Handle union types in arrays (anyOf, oneOf)
98
+ if "anyOf" in items:
99
+ union_types = _parse_union_types(items, "anyOf", field_name)
100
+ return f"array[{union_types}]"
101
+ elif "oneOf" in items:
102
+ union_types = _parse_union_types(items, "oneOf", field_name)
103
+ return f"array[{union_types}]"
104
+ # Handle object references
105
+ elif items_type == "object" and "$ref" in items:
106
+ ref_name = _extract_ref_name(items["$ref"])
107
+ return f"array[{ref_name}]"
108
+ elif "$ref" in items:
109
+ ref_name = _extract_ref_name(items["$ref"])
110
+ return f"array[{ref_name}]"
111
+ # Handle nested arrays
112
+ elif items_type == "array":
113
+ nested_array_type = _parse_array_type(items, field_name)
114
+ return f"array[{nested_array_type}]"
115
+ else:
116
+ return f"array[{items_type or 'object'}]"
117
+
118
+
119
+ def _is_beanie_link_schema(option: dict) -> bool:
120
+ """Check if this schema represents a Beanie Link."""
121
+ if option.get("type") != "object":
122
+ return False
123
+
124
+ properties = option.get("properties", {})
125
+ required = option.get("required", [])
126
+
127
+ # Beanie Link has id and collection properties
128
+ return (
129
+ "id" in properties
130
+ and "collection" in properties
131
+ and "id" in required
132
+ and "collection" in required
133
+ and len(properties) == 2
134
+ )
135
+
136
+
137
+ def _infer_link_target_from_field_name(field_name: str) -> str:
138
+ """Infer the target model type from field name patterns."""
139
+ # Common patterns for field names that reference other models
140
+ patterns = {
141
+ "oauth_accounts": "OAuthAccountModel",
142
+ "accounts": "AccountModel",
143
+ "users": "UserModel",
144
+ "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
+ "blobs": "BlobModel",
154
+ "blob": "BlobModel",
155
+ }
156
+
157
+ # Direct lookup
158
+ if field_name in patterns:
159
+ return patterns[field_name]
160
+
161
+ # Try singular/plural conversions
162
+ if field_name.endswith("s"):
163
+ singular = field_name[:-1]
164
+ if singular in patterns:
165
+ return patterns[singular]
166
+
167
+ # Pattern-based inference (field_name -> FieldNameModel)
168
+ if "_" in field_name:
169
+ # Convert snake_case to PascalCase
170
+ parts = field_name.split("_")
171
+ model_name = "".join(word.capitalize() for word in parts) + "Model"
172
+ return model_name
173
+ else:
174
+ # Simple case: field_name -> FieldNameModel
175
+ return field_name.capitalize() + "Model"
176
+
177
+
178
+ def _process_union_option(option: dict) -> tuple[str | None, bool]:
179
+ """Process a single union option, return (type_name, is_link)."""
180
+ if "type" in option:
181
+ if _is_beanie_link_schema(option):
182
+ return None, True
183
+ else:
184
+ return option["type"], False
185
+ elif "$ref" in option:
186
+ ref_name = _extract_ref_name(option["$ref"])
187
+ return ref_name, False
188
+ elif "const" in option:
189
+ return f"'{option['const']}'", False
190
+ else:
191
+ if option.get("type") == "object" and option.get("additionalProperties"):
192
+ return None, False # Skip generic objects from Links
193
+ return "object", False
194
+
195
+
196
+ def _parse_union_types(field_info: dict, union_key: str, field_name: str = "") -> str:
197
+ """Parse union types (anyOf, oneOf) from JSON schema."""
198
+ types = []
199
+ has_link = False
200
+
201
+ for option in field_info[union_key]:
202
+ type_name, is_link = _process_union_option(option)
203
+ if is_link:
204
+ has_link = True
205
+ elif type_name:
206
+ types.append(type_name)
207
+
208
+ if not types:
209
+ return union_key
210
+
211
+ # Add Link indicator with inferred target type
212
+ if has_link:
213
+ if field_name:
214
+ target_type = _infer_link_target_from_field_name(field_name)
215
+ return f"Link[{target_type}]"
216
+ else:
217
+ return f"Link[{types[0] if types else 'object'}]"
218
+
219
+ # If we have many types, show count to keep display clean
220
+ if len(types) > 4:
221
+ return f"{len(types)} types"
222
+
223
+ return " | ".join(types)
224
+
225
+
226
+ def _handle_fallback_type(field_info: dict, field_name: str) -> str:
227
+ """Handle fallback type inference when no explicit type is provided."""
228
+ if "properties" in field_info:
229
+ return "object"
230
+ elif "items" in field_info:
231
+ return "array"
232
+ elif "format" in field_info:
233
+ return field_info["format"]
234
+ else:
235
+ return field_name.split("_")[-1] if "_" in field_name else "any"
236
+
237
+
238
+ def _parse_field_type(field_info: dict, field_name: str) -> str:
239
+ """Parse field type from JSON schema field info."""
240
+ field_type = field_info.get("type", "")
241
+
242
+ # Handle array types
243
+ if field_type == "array":
244
+ return _parse_array_type(field_info, field_name)
245
+
246
+ # Handle object references
247
+ if "$ref" in field_info:
248
+ return _extract_ref_name(field_info["$ref"])
249
+
250
+ # Handle union types
251
+ if "anyOf" in field_info:
252
+ return _parse_union_types(field_info, "anyOf", field_name)
253
+
254
+ if "oneOf" in field_info:
255
+ return _parse_union_types(field_info, "oneOf", field_name)
256
+
257
+ # Handle inheritance
258
+ if "allOf" in field_info:
259
+ return "object"
260
+
261
+ # Handle const values
262
+ if "const" in field_info:
263
+ return f"const({field_info['const']})"
264
+
265
+ # Handle enum values
266
+ if "enum" in field_info:
267
+ return f"enum({len(field_info['enum'])} values)"
268
+
269
+ # Fallback type inference
270
+ if not field_type:
271
+ return _handle_fallback_type(field_info, field_name)
272
+
273
+ return field_type
274
+
275
+
276
+ def _get_extra_field_info(field_info: dict) -> dict:
277
+ """Extract additional field metadata."""
278
+ extra = {}
279
+
280
+ # Add constraints if present
281
+ if "minimum" in field_info:
282
+ extra["min"] = field_info["minimum"]
283
+ if "maximum" in field_info:
284
+ extra["max"] = field_info["maximum"]
285
+ if "minLength" in field_info:
286
+ extra["min_length"] = field_info["minLength"]
287
+ if "maxLength" in field_info:
288
+ extra["max_length"] = field_info["maxLength"]
289
+ if "pattern" in field_info:
290
+ extra["pattern"] = field_info["pattern"]
291
+ if "format" in field_info:
292
+ extra["format"] = field_info["format"]
293
+ if "default" in field_info:
294
+ extra["default"] = field_info["default"]
295
+ if "enum" in field_info:
296
+ enum_values = field_info["enum"]
297
+ if len(enum_values) <= 5:
298
+ extra["enum"] = enum_values
299
+ else:
300
+ extra["enum_count"] = len(enum_values)
301
+
302
+ return extra
303
+
304
+
305
+ def _extract_fields_from_schema(schema: dict) -> list[dict]:
306
+ """Extract field information from JSON schema."""
307
+ fields: list[dict] = []
308
+
309
+ if "properties" not in schema:
310
+ return fields
311
+
312
+ for field_name, field_info in schema["properties"].items():
313
+ field_type = _parse_field_type(field_info, field_name)
314
+ field_description = field_info.get("description", "")
315
+ required = field_name in schema.get("required", [])
316
+ extra_info = _get_extra_field_info(field_info)
317
+
318
+ fields.append(
319
+ {
320
+ "name": field_name,
321
+ "type": field_type,
322
+ "required": required,
323
+ "description": field_description,
324
+ "extra": extra_info,
325
+ }
326
+ )
327
+
328
+ return fields
329
+
330
+
331
+ def _get_collection_info(model) -> dict:
332
+ """Extract collection information from a Beanie model."""
333
+ if hasattr(model, "Settings") and hasattr(model.Settings, "name"):
334
+ collection_name = model.Settings.name
335
+ else:
336
+ collection_name = model.__name__.lower()
337
+
338
+ schema = model.model_json_schema()
339
+ fields = _extract_fields_from_schema(schema)
340
+
341
+ return {
342
+ "name": collection_name,
343
+ "model_name": model.__name__,
344
+ "fields": fields,
345
+ "total_fields": len(fields),
346
+ }
347
+
348
+
349
+ @router.get("/collections", response_class=HTMLResponse)
350
+ def debug_collections(request: Request):
351
+ """Debug endpoint to display MongoDB collection schemas."""
352
+ collections_info = [_get_collection_info(model) for model in get_all_models()]
353
+
354
+ return render_template(
355
+ "debug/collections.html.jinja", request, {"collections": collections_info}
356
+ )
357
+
358
+
359
+ @router.get("/users", response_class=HTMLResponse)
360
+ async def debug_users(request: Request):
361
+ """Debug endpoint to list and impersonate users."""
362
+
363
+ users = await UserModel.find_all().to_list()
364
+ current_user_id = (
365
+ request.session.get("user", {}).get("id")
366
+ if isinstance(request.session.get("user"), dict)
367
+ else request.session.get("user")
368
+ )
369
+
370
+ return render_template(
371
+ "debug/users.html.jinja",
372
+ request,
373
+ {"users": users, "current_user_id": current_user_id},
374
+ )
375
+
376
+
377
+ @router.post("/impersonate/{user_id}")
378
+ async def debug_impersonate_user(request: Request, user_id: str):
379
+ """Impersonate a user by setting their ID in the session."""
380
+ # Double check debug mode for security
381
+ if not ctx.DEBUG:
382
+ raise HTTPException(status_code=404, detail="Not found")
383
+
384
+ # Verify user exists
385
+ user = await UserModel.get(user_id)
386
+ if not user:
387
+ raise HTTPException(status_code=404, detail="User not found")
388
+
389
+ # Set full user session data (using the proper session_dict method)
390
+ request.session["user"] = user.session_dict
391
+
392
+ return RedirectResponse(url="/", status_code=302)
393
+
394
+
395
+ @router.post("/stop-impersonation")
396
+ async def debug_stop_impersonation(request: Request):
397
+ """Stop impersonating and clear user session."""
398
+ # Double check debug mode for security
399
+ if not ctx.DEBUG:
400
+ raise HTTPException(status_code=404, detail="Not found")
401
+
402
+ request.session.pop("user", None)
403
+ return RedirectResponse(url="/debug/users", status_code=302)
404
+
405
+
406
+ @router.get("/clear-session")
407
+ async def debug_clear_session(request: Request):
408
+ """Clear all session data to fix corrupted sessions."""
409
+ # Double check debug mode for security
410
+ if not ctx.DEBUG:
411
+ raise HTTPException(status_code=404, detail="Not found")
412
+
413
+ request.session.clear()
414
+ return RedirectResponse(url="/debug/users?cleared=1", status_code=302)
@@ -0,0 +1,33 @@
1
+ import os
2
+ from datetime import datetime
3
+
4
+ from fastapi import APIRouter
5
+
6
+ from vibetuner.config import settings
7
+ from vibetuner.paths import root as root_path
8
+
9
+
10
+ router = APIRouter(prefix="/health")
11
+
12
+ # Store startup time for instance identification
13
+ _startup_time = datetime.now()
14
+
15
+
16
+ @router.get("/ping")
17
+ def health_ping():
18
+ """Simple health check endpoint"""
19
+ return {"ping": "ok"}
20
+
21
+
22
+ @router.get("/id")
23
+ def health_instance_id():
24
+ """Instance identification endpoint for distinguishing app instances"""
25
+ return {
26
+ "app": settings.project.project_slug,
27
+ "port": int(os.environ.get("PORT", 8000)),
28
+ "debug": settings.debug,
29
+ "status": "healthy",
30
+ "root_path": str(root_path.resolve()),
31
+ "process_id": os.getpid(),
32
+ "startup_time": _startup_time.isoformat(),
33
+ }
@@ -0,0 +1,43 @@
1
+ from babel import Locale
2
+ from fastapi import APIRouter, Depends, Request
3
+ from fastapi.responses import HTMLResponse, RedirectResponse
4
+
5
+ from ..deps import require_htmx
6
+ from ..lifespan import ctx
7
+ from ..templates import render_template
8
+
9
+
10
+ router = APIRouter()
11
+
12
+ LOCALE_NAMES: dict[str, str] = dict(
13
+ sorted(
14
+ {
15
+ locale: (Locale.parse(locale).display_name or locale).capitalize()
16
+ for locale in ctx.supported_languages
17
+ }.items(),
18
+ key=lambda x: x[1],
19
+ ),
20
+ )
21
+
22
+
23
+ @router.get("/set-language/{lang}")
24
+ async def set_language(request: Request, lang: str, current: str) -> RedirectResponse:
25
+ new_url = f"/{lang}{current[3:]}" if current else request.url_for("homepage").path
26
+ response = RedirectResponse(url=new_url)
27
+ response.set_cookie(key="language", value=lang, max_age=3600)
28
+
29
+ return response
30
+
31
+
32
+ @router.get("/get-languages", dependencies=[Depends(require_htmx)])
33
+ async def get_languages(request: Request) -> HTMLResponse:
34
+ """Return a list of supported languages."""
35
+
36
+ return render_template(
37
+ "lang/select.html.jinja",
38
+ request=request,
39
+ ctx={
40
+ "locale_names": LOCALE_NAMES,
41
+ "current_language": request.state.language,
42
+ },
43
+ )
@@ -0,0 +1,55 @@
1
+ from pathlib import Path
2
+
3
+ from fastapi import APIRouter, Request
4
+ from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse
5
+
6
+ from vibetuner.paths import fallback_static_default
7
+
8
+ from ..templates import render_template
9
+
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ # Favicon Related Routes
15
+ # Todo, provide an easy way to override default statics
16
+ @router.get("/favicon.ico", response_class=FileResponse)
17
+ async def favicon() -> Path:
18
+ return fallback_static_default("favicons", "favicon.ico")
19
+
20
+
21
+ # Misc static routes
22
+ @router.get("/robots.txt", response_class=PlainTextResponse)
23
+ def robots(request: Request) -> HTMLResponse:
24
+ return render_template(
25
+ "meta/robots.txt.jinja",
26
+ request=request,
27
+ media_type="text/plain",
28
+ )
29
+
30
+
31
+ @router.get("/sitemap.xml")
32
+ async def sitemap(request: Request) -> HTMLResponse:
33
+ return render_template(
34
+ "meta/sitemap.xml.jinja",
35
+ request,
36
+ media_type="application/xml",
37
+ )
38
+
39
+
40
+ @router.get("/site.webmanifest")
41
+ async def site_webmanifest(request: Request) -> HTMLResponse:
42
+ return render_template(
43
+ "meta/site.webmanifest.jinja",
44
+ request,
45
+ media_type="application/manifest+json",
46
+ )
47
+
48
+
49
+ @router.get("/browserconfig.xml")
50
+ async def browserconfig(request: Request) -> HTMLResponse:
51
+ return render_template(
52
+ "meta/browserconfig.xml.jinja",
53
+ request,
54
+ media_type="application/xml",
55
+ )