fastapi-fullstack 0.1.2__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.
- fastapi_fullstack-0.1.2.dist-info/METADATA +545 -0
- fastapi_fullstack-0.1.2.dist-info/RECORD +221 -0
- fastapi_fullstack-0.1.2.dist-info/WHEEL +4 -0
- fastapi_fullstack-0.1.2.dist-info/entry_points.txt +2 -0
- fastapi_fullstack-0.1.2.dist-info/licenses/LICENSE +21 -0
- fastapi_gen/__init__.py +3 -0
- fastapi_gen/cli.py +256 -0
- fastapi_gen/config.py +255 -0
- fastapi_gen/generator.py +181 -0
- fastapi_gen/prompts.py +648 -0
- fastapi_gen/template/cookiecutter.json +76 -0
- fastapi_gen/template/hooks/post_gen_project.py +111 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/.env.example +136 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/.github/workflows/ci.yml +150 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/.gitignore +108 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/CLAUDE.md +357 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/Makefile +298 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/README.md +723 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/.dockerignore +60 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/.pre-commit-config.yaml +32 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/Dockerfile +56 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/alembic/env.py +76 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/alembic/script.py.mako +30 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/alembic/versions/.gitkeep +0 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/alembic.ini +48 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/__init__.py +3 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/admin.py +115 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/__init__.py +13 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/assistant.py +202 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/__init__.py +13 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/datetime_tool.py +17 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/__init__.py +1 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/deps.py +528 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/exception_handlers.py +85 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/router.py +10 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/__init__.py +9 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/__init__.py +87 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/agent.py +448 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/auth.py +395 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/conversations.py +490 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/health.py +227 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/items.py +275 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/oauth.py +205 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/sessions.py +168 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/users.py +333 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/webhooks.py +477 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/routes/v1/ws.py +46 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/api/versioning.py +221 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/clients/__init__.py +14 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/clients/redis.py +88 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/commands/__init__.py +117 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/commands/cleanup.py +75 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/commands/example.py +28 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/commands/seed.py +266 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/__init__.py +5 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/cache.py +23 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/config.py +247 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/csrf.py +153 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/exceptions.py +122 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/logfire_setup.py +101 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/middleware.py +99 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/oauth.py +23 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/rate_limit.py +58 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/sanitize.py +271 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/core/security.py +102 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/__init__.py +7 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/base.py +41 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/__init__.py +31 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/conversation.py +319 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/item.py +96 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/session.py +126 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/user.py +218 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/models/webhook.py +244 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/db/session.py +113 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/main.py +326 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/pipelines/__init__.py +9 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/pipelines/base.py +73 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/repositories/__init__.py +49 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/repositories/base.py +154 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/repositories/conversation.py +760 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/repositories/item.py +222 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/repositories/session.py +318 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/repositories/user.py +322 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/repositories/webhook.py +358 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/schemas/__init__.py +50 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/schemas/base.py +57 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/schemas/conversation.py +195 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/schemas/item.py +52 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/schemas/session.py +42 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/schemas/token.py +31 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/schemas/user.py +64 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/schemas/webhook.py +89 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/services/__init__.py +38 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/services/conversation.py +797 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/services/item.py +246 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/services/session.py +333 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/services/user.py +432 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/services/webhook.py +561 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/worker/__init__.py +5 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/worker/celery_app.py +64 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/worker/taskiq_app.py +38 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/worker/tasks/__init__.py +25 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/worker/tasks/examples.py +106 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/worker/tasks/schedules.py +29 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/app/worker/tasks/taskiq_examples.py +92 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/cli/__init__.py +1 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/cli/commands.py +438 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/pyproject.toml +158 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/scripts/.gitkeep +0 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/__init__.py +1 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/api/__init__.py +1 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/api/test_auth.py +242 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/api/test_exceptions.py +151 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/api/test_health.py +113 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/api/test_items.py +310 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/api/test_users.py +253 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/conftest.py +151 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/test_agents.py +121 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/test_clients.py +183 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/test_commands.py +173 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/test_core.py +143 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/test_pipelines.py +118 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/test_repositories.py +181 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/test_security.py +124 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/test_services.py +363 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/backend/tests/test_worker.py +85 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.dev.yml +242 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.frontend.yml +31 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.prod.yml +382 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/docker-compose.yml +241 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/.env.example +12 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/.gitignore +45 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/.prettierignore +19 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/.prettierrc +11 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/Dockerfile +44 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/README.md +693 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/e2e/auth.setup.ts +49 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/e2e/auth.spec.ts +134 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/e2e/chat.spec.ts +207 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/e2e/home.spec.ts +73 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/instrumentation.ts +14 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/messages/en.json +84 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/messages/pl.json +84 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/next.config.ts +76 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/package.json +66 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/playwright.config.ts +101 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/postcss.config.mjs +7 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/(auth)/layout.tsx +11 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/(auth)/login/page.tsx +5 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/(auth)/register/page.tsx +5 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/(dashboard)/chat/page.tsx +20 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/(dashboard)/dashboard/page.tsx +99 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/(dashboard)/layout.tsx +17 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/(dashboard)/profile/page.tsx +156 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/login/route.ts +58 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/logout/route.ts +24 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/me/route.ts +39 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/oauth-callback/route.ts +50 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/refresh/route.ts +54 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/auth/register/route.ts +26 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/conversations/[id]/messages/route.ts +41 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/conversations/[id]/route.ts +108 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/conversations/route.ts +73 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/api/health/route.ts +21 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/auth/callback/page.tsx +96 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/globals.css +108 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/layout.tsx +25 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/page.tsx +73 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/app/providers.tsx +29 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/auth/index.ts +2 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/auth/login-form.tsx +120 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/auth/register-form.tsx +153 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/chat-container.tsx +135 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/chat-input.tsx +73 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/conversation-sidebar.tsx +261 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/index.ts +8 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/message-item.tsx +63 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/message-list.tsx +18 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/tool-call-card.tsx +60 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/icons/google-icon.tsx +32 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/icons/index.ts +3 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/language-switcher.tsx +97 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/layout/header.tsx +45 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/layout/index.ts +2 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/layout/sidebar.tsx +48 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/theme/index.ts +7 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/theme/theme-provider.tsx +53 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/theme/theme-toggle.tsx +83 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/badge.tsx +35 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/button.test.tsx +75 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/button.tsx +54 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/card.tsx +82 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/index.ts +12 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/input.tsx +21 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/label.tsx +21 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/hooks/index.ts +6 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/hooks/use-auth.ts +97 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/hooks/use-chat.ts +203 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/hooks/use-conversations.ts +175 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/hooks/use-websocket.ts +105 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/i18n.ts +32 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/api-client.ts +90 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/constants.ts +39 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/server-api.ts +78 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/utils.test.ts +44 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/lib/utils.ts +44 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/middleware.ts +33 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/stores/auth-store.test.ts +72 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/stores/auth-store.ts +48 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/stores/chat-store.ts +65 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/stores/conversation-store.ts +76 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/stores/index.ts +6 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/stores/theme-store.ts +44 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/types/api.ts +27 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/types/auth.ts +52 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/types/chat.ts +81 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/types/conversation.ts +49 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/src/types/index.ts +10 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/tsconfig.json +28 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/vitest.config.ts +36 -0
- fastapi_gen/template/{{cookiecutter.project_slug}}/frontend/vitest.setup.ts +56 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Application exceptions.
|
|
2
|
+
|
|
3
|
+
Domain exceptions with HTTP status codes for the hybrid approach.
|
|
4
|
+
These exceptions are caught by exception handlers and converted to proper HTTP responses.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AppException(Exception):
|
|
11
|
+
"""Base exception for all application errors.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
message: Human-readable error message.
|
|
15
|
+
code: Machine-readable error code for clients.
|
|
16
|
+
status_code: HTTP status code to return.
|
|
17
|
+
details: Additional error details (e.g., field names, IDs).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
message: str = "An error occurred"
|
|
21
|
+
code: str = "APP_ERROR"
|
|
22
|
+
status_code: int = 500
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
message: str | None = None,
|
|
27
|
+
code: str | None = None,
|
|
28
|
+
details: dict[str, Any] | None = None,
|
|
29
|
+
):
|
|
30
|
+
self.message = message or self.__class__.message
|
|
31
|
+
self.code = code or self.__class__.code
|
|
32
|
+
self.details = details or {}
|
|
33
|
+
super().__init__(self.message)
|
|
34
|
+
|
|
35
|
+
def __repr__(self) -> str:
|
|
36
|
+
return f"{self.__class__.__name__}(message={self.message!r}, code={self.code!r})"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# === 4xx Client Errors ===
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class NotFoundError(AppException):
|
|
43
|
+
"""Resource not found (404)."""
|
|
44
|
+
|
|
45
|
+
message = "Resource not found"
|
|
46
|
+
code = "NOT_FOUND"
|
|
47
|
+
status_code = 404
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AlreadyExistsError(AppException):
|
|
51
|
+
"""Resource already exists (409)."""
|
|
52
|
+
|
|
53
|
+
message = "Resource already exists"
|
|
54
|
+
code = "ALREADY_EXISTS"
|
|
55
|
+
status_code = 409
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ValidationError(AppException):
|
|
59
|
+
"""Validation error (422)."""
|
|
60
|
+
|
|
61
|
+
message = "Validation error"
|
|
62
|
+
code = "VALIDATION_ERROR"
|
|
63
|
+
status_code = 422
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AuthenticationError(AppException):
|
|
67
|
+
"""Authentication failed (401)."""
|
|
68
|
+
|
|
69
|
+
message = "Authentication failed"
|
|
70
|
+
code = "AUTHENTICATION_ERROR"
|
|
71
|
+
status_code = 401
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AuthorizationError(AppException):
|
|
75
|
+
"""Authorization failed - insufficient permissions (403)."""
|
|
76
|
+
|
|
77
|
+
message = "Insufficient permissions"
|
|
78
|
+
code = "AUTHORIZATION_ERROR"
|
|
79
|
+
status_code = 403
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class RateLimitError(AppException):
|
|
83
|
+
"""Rate limit exceeded (429)."""
|
|
84
|
+
|
|
85
|
+
message = "Rate limit exceeded"
|
|
86
|
+
code = "RATE_LIMIT_EXCEEDED"
|
|
87
|
+
status_code = 429
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class BadRequestError(AppException):
|
|
91
|
+
"""Bad request (400)."""
|
|
92
|
+
|
|
93
|
+
message = "Bad request"
|
|
94
|
+
code = "BAD_REQUEST"
|
|
95
|
+
status_code = 400
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# === 5xx Server Errors ===
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ExternalServiceError(AppException):
|
|
102
|
+
"""External service unavailable (503)."""
|
|
103
|
+
|
|
104
|
+
message = "External service unavailable"
|
|
105
|
+
code = "EXTERNAL_SERVICE_ERROR"
|
|
106
|
+
status_code = 503
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class DatabaseError(AppException):
|
|
110
|
+
"""Database error (500)."""
|
|
111
|
+
|
|
112
|
+
message = "Database error"
|
|
113
|
+
code = "DATABASE_ERROR"
|
|
114
|
+
status_code = 500
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class InternalError(AppException):
|
|
118
|
+
"""Internal server error (500)."""
|
|
119
|
+
|
|
120
|
+
message = "Internal server error"
|
|
121
|
+
code = "INTERNAL_ERROR"
|
|
122
|
+
status_code = 500
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
{%- if cookiecutter.enable_logfire %}
|
|
2
|
+
"""Logfire observability configuration."""
|
|
3
|
+
|
|
4
|
+
import logfire
|
|
5
|
+
|
|
6
|
+
from app.core.config import settings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def setup_logfire() -> None:
|
|
10
|
+
"""Configure Logfire instrumentation."""
|
|
11
|
+
logfire.configure(
|
|
12
|
+
token=settings.LOGFIRE_TOKEN,
|
|
13
|
+
service_name=settings.LOGFIRE_SERVICE_NAME,
|
|
14
|
+
environment=settings.LOGFIRE_ENVIRONMENT,
|
|
15
|
+
send_to_logfire="if-token-present",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def instrument_app(app):
|
|
20
|
+
"""Instrument FastAPI app with Logfire."""
|
|
21
|
+
{%- if cookiecutter.logfire_fastapi %}
|
|
22
|
+
logfire.instrument_fastapi(app)
|
|
23
|
+
{%- else %}
|
|
24
|
+
pass
|
|
25
|
+
{%- endif %}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
{%- if cookiecutter.use_postgresql and cookiecutter.logfire_database %}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def instrument_asyncpg():
|
|
32
|
+
"""Instrument asyncpg for PostgreSQL."""
|
|
33
|
+
logfire.instrument_asyncpg()
|
|
34
|
+
{%- endif %}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
{%- if cookiecutter.use_mongodb and cookiecutter.logfire_database %}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def instrument_pymongo():
|
|
41
|
+
"""Instrument PyMongo/Motor for MongoDB."""
|
|
42
|
+
logfire.instrument_pymongo(capture_statement=settings.DEBUG)
|
|
43
|
+
{%- endif %}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
{%- if cookiecutter.use_sqlite and cookiecutter.logfire_database %}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def instrument_sqlalchemy(engine):
|
|
50
|
+
"""Instrument SQLAlchemy for SQLite."""
|
|
51
|
+
logfire.instrument_sqlalchemy(engine=engine)
|
|
52
|
+
{%- endif %}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
{%- if cookiecutter.enable_redis and cookiecutter.logfire_redis %}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def instrument_redis():
|
|
59
|
+
"""Instrument Redis."""
|
|
60
|
+
logfire.instrument_redis()
|
|
61
|
+
{%- endif %}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
{%- if cookiecutter.use_celery and cookiecutter.logfire_celery %}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def instrument_celery():
|
|
68
|
+
"""Instrument Celery."""
|
|
69
|
+
logfire.instrument_celery()
|
|
70
|
+
{%- endif %}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
{%- if cookiecutter.logfire_httpx %}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def instrument_httpx():
|
|
77
|
+
"""Instrument HTTPX for outgoing HTTP requests."""
|
|
78
|
+
logfire.instrument_httpx()
|
|
79
|
+
{%- endif %}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
{%- if cookiecutter.enable_ai_agent %}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def instrument_pydantic_ai():
|
|
86
|
+
"""Instrument PydanticAI for AI agent observability."""
|
|
87
|
+
logfire.instrument_pydantic_ai()
|
|
88
|
+
{%- endif %}
|
|
89
|
+
{%- else %}
|
|
90
|
+
"""Logfire is disabled for this project."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def setup_logfire() -> None:
|
|
94
|
+
"""No-op when Logfire is disabled."""
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def instrument_app(app):
|
|
99
|
+
"""No-op when Logfire is disabled."""
|
|
100
|
+
pass
|
|
101
|
+
{%- endif %}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Application middleware."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
7
|
+
from starlette.requests import Request
|
|
8
|
+
from starlette.responses import Response
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RequestIDMiddleware(BaseHTTPMiddleware):
|
|
12
|
+
"""Middleware that adds a unique request ID to each request.
|
|
13
|
+
|
|
14
|
+
The request ID is taken from the X-Request-ID header if present,
|
|
15
|
+
otherwise a new UUID is generated. The ID is added to the response
|
|
16
|
+
headers and is available in request.state.request_id.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
20
|
+
"""Add request ID to request state and response headers."""
|
|
21
|
+
request_id = request.headers.get("X-Request-ID", str(uuid4()))
|
|
22
|
+
request.state.request_id = request_id
|
|
23
|
+
|
|
24
|
+
response = await call_next(request)
|
|
25
|
+
response.headers["X-Request-ID"] = request_id
|
|
26
|
+
return response
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|
30
|
+
"""Middleware that adds security headers to all responses.
|
|
31
|
+
|
|
32
|
+
This includes:
|
|
33
|
+
- Content-Security-Policy (CSP)
|
|
34
|
+
- X-Content-Type-Options
|
|
35
|
+
- X-Frame-Options
|
|
36
|
+
- X-XSS-Protection
|
|
37
|
+
- Referrer-Policy
|
|
38
|
+
- Permissions-Policy
|
|
39
|
+
|
|
40
|
+
Usage:
|
|
41
|
+
app.add_middleware(SecurityHeadersMiddleware)
|
|
42
|
+
|
|
43
|
+
# Or with custom CSP:
|
|
44
|
+
app.add_middleware(
|
|
45
|
+
SecurityHeadersMiddleware,
|
|
46
|
+
csp_directives={
|
|
47
|
+
"default-src": "'self'",
|
|
48
|
+
"script-src": "'self' 'unsafe-inline'",
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
DEFAULT_CSP_DIRECTIVES: ClassVar[dict[str, str]] = {
|
|
54
|
+
"default-src": "'self'",
|
|
55
|
+
"script-src": "'self'",
|
|
56
|
+
"style-src": "'self' 'unsafe-inline'", # Allow inline styles for some UI libs
|
|
57
|
+
"img-src": "'self' data: https:",
|
|
58
|
+
"font-src": "'self' data:",
|
|
59
|
+
"connect-src": "'self'",
|
|
60
|
+
"frame-ancestors": "'none'",
|
|
61
|
+
"base-uri": "'self'",
|
|
62
|
+
"form-action": "'self'",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
app,
|
|
68
|
+
csp_directives: dict | None = None,
|
|
69
|
+
exclude_paths: set | None = None,
|
|
70
|
+
):
|
|
71
|
+
super().__init__(app)
|
|
72
|
+
self.csp_directives = csp_directives or self.DEFAULT_CSP_DIRECTIVES
|
|
73
|
+
self.exclude_paths = exclude_paths or {"/docs", "/redoc", "/openapi.json"}
|
|
74
|
+
|
|
75
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
76
|
+
"""Add security headers to the response."""
|
|
77
|
+
response = await call_next(request)
|
|
78
|
+
|
|
79
|
+
# Skip for docs/openapi endpoints which need different CSP
|
|
80
|
+
if request.url.path in self.exclude_paths:
|
|
81
|
+
return response
|
|
82
|
+
|
|
83
|
+
# Build CSP header
|
|
84
|
+
csp_value = "; ".join(
|
|
85
|
+
f"{directive} {value}" for directive, value in self.csp_directives.items()
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Add security headers
|
|
89
|
+
response.headers["Content-Security-Policy"] = csp_value
|
|
90
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
91
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
92
|
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
93
|
+
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
94
|
+
response.headers["Permissions-Policy"] = (
|
|
95
|
+
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), "
|
|
96
|
+
"magnetometer=(), microphone=(), payment=(), usb=()"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return response
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{%- if cookiecutter.enable_oauth %}
|
|
2
|
+
"""OAuth2 client configuration."""
|
|
3
|
+
|
|
4
|
+
from authlib.integrations.starlette_client import OAuth
|
|
5
|
+
|
|
6
|
+
from app.core.config import settings
|
|
7
|
+
|
|
8
|
+
oauth = OAuth()
|
|
9
|
+
|
|
10
|
+
{%- if cookiecutter.enable_oauth_google %}
|
|
11
|
+
|
|
12
|
+
# Configure Google OAuth2
|
|
13
|
+
oauth.register(
|
|
14
|
+
name="google",
|
|
15
|
+
client_id=settings.GOOGLE_CLIENT_ID,
|
|
16
|
+
client_secret=settings.GOOGLE_CLIENT_SECRET,
|
|
17
|
+
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
|
18
|
+
client_kwargs={"scope": "openid email profile"},
|
|
19
|
+
)
|
|
20
|
+
{%- endif %}
|
|
21
|
+
{%- else %}
|
|
22
|
+
"""OAuth module - not configured."""
|
|
23
|
+
{%- endif %}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{%- if cookiecutter.enable_rate_limiting %}
|
|
2
|
+
"""Rate limiting configuration using slowapi.
|
|
3
|
+
|
|
4
|
+
Default rate limit: {{ cookiecutter.rate_limit_requests }} requests per {{ cookiecutter.rate_limit_period }} seconds.
|
|
5
|
+
Override with RATE_LIMIT_REQUESTS and RATE_LIMIT_PERIOD environment variables.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from slowapi import Limiter
|
|
9
|
+
from slowapi.util import get_remote_address
|
|
10
|
+
|
|
11
|
+
from app.core.config import settings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_default_rate_limit() -> str:
|
|
15
|
+
"""Get default rate limit string from settings.
|
|
16
|
+
|
|
17
|
+
Returns a rate limit string like "100/minute" or "60/second".
|
|
18
|
+
"""
|
|
19
|
+
requests = settings.RATE_LIMIT_REQUESTS
|
|
20
|
+
period = settings.RATE_LIMIT_PERIOD
|
|
21
|
+
|
|
22
|
+
# Convert period to a human-readable format
|
|
23
|
+
period_map = {
|
|
24
|
+
60: "minute",
|
|
25
|
+
3600: "hour",
|
|
26
|
+
86400: "day",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if period in period_map:
|
|
30
|
+
return f"{requests}/{period_map[period]}"
|
|
31
|
+
# For custom periods, use "per X seconds"
|
|
32
|
+
return f"{requests}/{period} seconds"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Rate limiter instance with configurable default
|
|
36
|
+
limiter = Limiter(
|
|
37
|
+
key_func=get_remote_address,
|
|
38
|
+
default_limits=[get_default_rate_limit()],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Common rate limit decorators for convenience
|
|
42
|
+
# Usage: @rate_limit_low, @rate_limit_medium, @rate_limit_high
|
|
43
|
+
def rate_limit_low(limit: str = "10/minute"):
|
|
44
|
+
"""Low rate limit for expensive operations."""
|
|
45
|
+
return limiter.limit(limit)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def rate_limit_medium(limit: str = "30/minute"):
|
|
49
|
+
"""Medium rate limit for standard operations."""
|
|
50
|
+
return limiter.limit(limit)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def rate_limit_high(limit: str = "100/minute"):
|
|
54
|
+
"""High rate limit for lightweight operations."""
|
|
55
|
+
return limiter.limit(limit)
|
|
56
|
+
{%- else %}
|
|
57
|
+
"""Rate limiting - not configured."""
|
|
58
|
+
{%- endif %}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Input sanitization utilities.
|
|
2
|
+
|
|
3
|
+
This module provides security-focused input sanitization functions:
|
|
4
|
+
- HTML sanitization to prevent XSS attacks
|
|
5
|
+
- Path traversal prevention for file operations
|
|
6
|
+
- Common input cleaning utilities
|
|
7
|
+
|
|
8
|
+
Note: SQL injection is prevented by using SQLAlchemy ORM with parameterized queries.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import html
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import unicodedata
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TypeVar
|
|
17
|
+
|
|
18
|
+
# Default allowed HTML tags for rich text content
|
|
19
|
+
DEFAULT_ALLOWED_TAGS = frozenset({
|
|
20
|
+
"a", "abbr", "acronym", "b", "blockquote", "br", "code",
|
|
21
|
+
"em", "i", "li", "ol", "p", "pre", "strong", "ul",
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
# Default allowed HTML attributes
|
|
25
|
+
DEFAULT_ALLOWED_ATTRIBUTES = {
|
|
26
|
+
"a": frozenset({"href", "title", "rel"}),
|
|
27
|
+
"abbr": frozenset({"title"}),
|
|
28
|
+
"acronym": frozenset({"title"}),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def sanitize_html(
|
|
33
|
+
content: str,
|
|
34
|
+
allowed_tags: frozenset[str] | None = None,
|
|
35
|
+
strip: bool = True,
|
|
36
|
+
) -> str:
|
|
37
|
+
"""Sanitize HTML content to prevent XSS attacks.
|
|
38
|
+
|
|
39
|
+
This is a simple implementation that escapes all HTML.
|
|
40
|
+
For rich text support, consider using the `bleach` library.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
content: The HTML content to sanitize.
|
|
44
|
+
allowed_tags: Not used in simple mode (for bleach compatibility).
|
|
45
|
+
strip: Not used in simple mode (for bleach compatibility).
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Escaped HTML-safe string.
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
>>> sanitize_html("<script>alert('xss')</script>")
|
|
52
|
+
"<script>alert('xss')</script>"
|
|
53
|
+
"""
|
|
54
|
+
if not content:
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
return html.escape(content)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def sanitize_filename(filename: str, allow_unicode: bool = False) -> str:
|
|
61
|
+
"""Sanitize a filename to prevent path traversal and unsafe characters.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
filename: The filename to sanitize.
|
|
65
|
+
allow_unicode: Whether to allow unicode characters.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
A safe filename string.
|
|
69
|
+
|
|
70
|
+
Example:
|
|
71
|
+
>>> sanitize_filename("../../../etc/passwd")
|
|
72
|
+
"etc_passwd"
|
|
73
|
+
>>> sanitize_filename("hello world.txt")
|
|
74
|
+
"hello_world.txt"
|
|
75
|
+
"""
|
|
76
|
+
if not filename:
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
# Normalize unicode
|
|
80
|
+
if allow_unicode:
|
|
81
|
+
filename = unicodedata.normalize("NFKC", filename)
|
|
82
|
+
else:
|
|
83
|
+
filename = (
|
|
84
|
+
unicodedata.normalize("NFKD", filename)
|
|
85
|
+
.encode("ascii", "ignore")
|
|
86
|
+
.decode("ascii")
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Get just the filename (remove any path components)
|
|
90
|
+
filename = os.path.basename(filename)
|
|
91
|
+
|
|
92
|
+
# Remove null bytes
|
|
93
|
+
filename = filename.replace("\x00", "")
|
|
94
|
+
|
|
95
|
+
# Replace path separators and special characters
|
|
96
|
+
filename = re.sub(r"[/\\:*?\"<>|]", "_", filename)
|
|
97
|
+
|
|
98
|
+
# Replace multiple underscores/spaces with single underscore
|
|
99
|
+
filename = re.sub(r"[\s_]+", "_", filename)
|
|
100
|
+
|
|
101
|
+
# Remove leading/trailing underscores and dots
|
|
102
|
+
filename = filename.strip("._")
|
|
103
|
+
|
|
104
|
+
# Ensure we have a valid filename
|
|
105
|
+
if not filename:
|
|
106
|
+
return "unnamed"
|
|
107
|
+
|
|
108
|
+
return filename
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def validate_safe_path(
|
|
112
|
+
base_dir: Path | str,
|
|
113
|
+
user_path: str,
|
|
114
|
+
) -> Path:
|
|
115
|
+
"""Validate that a user-provided path is within the allowed base directory.
|
|
116
|
+
|
|
117
|
+
Prevents path traversal attacks by ensuring the resolved path
|
|
118
|
+
is within the expected directory.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
base_dir: The base directory that all paths must be within.
|
|
122
|
+
user_path: The user-provided path to validate.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The resolved, safe path.
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
ValueError: If the path would escape the base directory.
|
|
129
|
+
|
|
130
|
+
Example:
|
|
131
|
+
>>> validate_safe_path("/uploads", "../../../etc/passwd")
|
|
132
|
+
Raises ValueError
|
|
133
|
+
>>> validate_safe_path("/uploads", "images/photo.jpg")
|
|
134
|
+
Path("/uploads/images/photo.jpg")
|
|
135
|
+
"""
|
|
136
|
+
base_path = Path(base_dir).resolve()
|
|
137
|
+
user_path_sanitized = sanitize_filename(user_path.lstrip("/\\"))
|
|
138
|
+
|
|
139
|
+
# Resolve the full path
|
|
140
|
+
full_path = (base_path / user_path_sanitized).resolve()
|
|
141
|
+
|
|
142
|
+
# Check if the resolved path is within the base directory
|
|
143
|
+
try:
|
|
144
|
+
full_path.relative_to(base_path)
|
|
145
|
+
except ValueError as err:
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"Path traversal detected: {user_path!r} would escape {base_dir!r}"
|
|
148
|
+
) from err
|
|
149
|
+
|
|
150
|
+
return full_path
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def sanitize_string(
|
|
154
|
+
value: str,
|
|
155
|
+
max_length: int | None = None,
|
|
156
|
+
allow_newlines: bool = True,
|
|
157
|
+
strip_whitespace: bool = True,
|
|
158
|
+
) -> str:
|
|
159
|
+
"""Sanitize a string input with various options.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
value: The string to sanitize.
|
|
163
|
+
max_length: Maximum allowed length (truncates if exceeded).
|
|
164
|
+
allow_newlines: Whether to preserve newlines.
|
|
165
|
+
strip_whitespace: Whether to strip leading/trailing whitespace.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Sanitized string.
|
|
169
|
+
"""
|
|
170
|
+
if not value:
|
|
171
|
+
return ""
|
|
172
|
+
|
|
173
|
+
# Strip null bytes and other control characters (except newlines if allowed)
|
|
174
|
+
if allow_newlines:
|
|
175
|
+
value = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", value)
|
|
176
|
+
else:
|
|
177
|
+
value = re.sub(r"[\x00-\x1f\x7f]", "", value)
|
|
178
|
+
|
|
179
|
+
# Strip whitespace if requested
|
|
180
|
+
if strip_whitespace:
|
|
181
|
+
value = value.strip()
|
|
182
|
+
|
|
183
|
+
# Truncate if needed
|
|
184
|
+
if max_length is not None and len(value) > max_length:
|
|
185
|
+
value = value[:max_length]
|
|
186
|
+
|
|
187
|
+
return value
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def sanitize_email(email: str) -> str:
|
|
191
|
+
"""Basic email sanitization.
|
|
192
|
+
|
|
193
|
+
Note: For proper email validation, use Pydantic's EmailStr type.
|
|
194
|
+
This function only performs basic cleaning.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
email: The email address to sanitize.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Lowercased, stripped email.
|
|
201
|
+
"""
|
|
202
|
+
if not email:
|
|
203
|
+
return ""
|
|
204
|
+
|
|
205
|
+
return email.strip().lower()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
T = TypeVar("T", int, float)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def sanitize_numeric(
|
|
212
|
+
value: str | int | float,
|
|
213
|
+
value_type: type[T],
|
|
214
|
+
min_value: T | None = None,
|
|
215
|
+
max_value: T | None = None,
|
|
216
|
+
default: T | None = None,
|
|
217
|
+
) -> T | None:
|
|
218
|
+
"""Sanitize and validate a numeric value.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
value: The value to sanitize (can be string or numeric).
|
|
222
|
+
value_type: The expected type (int or float).
|
|
223
|
+
min_value: Minimum allowed value.
|
|
224
|
+
max_value: Maximum allowed value.
|
|
225
|
+
default: Default value if conversion fails.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
The sanitized numeric value, or default if invalid.
|
|
229
|
+
|
|
230
|
+
Example:
|
|
231
|
+
>>> sanitize_numeric("100", int, min_value=0, max_value=1000)
|
|
232
|
+
100
|
|
233
|
+
>>> sanitize_numeric("abc", int, default=0)
|
|
234
|
+
0
|
|
235
|
+
"""
|
|
236
|
+
try:
|
|
237
|
+
result = value_type(value)
|
|
238
|
+
|
|
239
|
+
if min_value is not None and result < min_value:
|
|
240
|
+
result = min_value
|
|
241
|
+
if max_value is not None and result > max_value:
|
|
242
|
+
result = max_value
|
|
243
|
+
|
|
244
|
+
return result
|
|
245
|
+
except (ValueError, TypeError):
|
|
246
|
+
return default
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def escape_sql_like(pattern: str, escape_char: str = "\\") -> str:
|
|
250
|
+
"""Escape special characters in a LIKE pattern.
|
|
251
|
+
|
|
252
|
+
Use this when building LIKE queries with user input.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
pattern: The pattern to escape.
|
|
256
|
+
escape_char: The escape character to use.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
Escaped pattern safe for use in LIKE queries.
|
|
260
|
+
|
|
261
|
+
Example:
|
|
262
|
+
>>> escape_sql_like("100%")
|
|
263
|
+
"100\\%"
|
|
264
|
+
>>> escape_sql_like("under_score")
|
|
265
|
+
"under\\_score"
|
|
266
|
+
"""
|
|
267
|
+
# Escape the escape character first, then special chars
|
|
268
|
+
pattern = pattern.replace(escape_char, escape_char + escape_char)
|
|
269
|
+
pattern = pattern.replace("%", escape_char + "%")
|
|
270
|
+
pattern = pattern.replace("_", escape_char + "_")
|
|
271
|
+
return pattern
|