Spaces:
Running
Running
Commit ·
5d6260a
1
Parent(s): f8c4791
feat: add supabase
Browse files- .gitignore +4 -1
- app/api/server.py +10 -26
- app/api/v1/auth.py +30 -52
- app/api/v1/url_shortener.py +13 -32
- app/api/v1/vector_stores.py +1 -5
- app/config.py +5 -8
- app/core/auth/deps.py +59 -247
- app/core/auth/schemas.py +0 -6
- app/core/banner.py +3 -3
- app/core/tidb_manager.py +0 -341
- app/core/vector_store/__init__.py +1 -10
- app/core/vector_store/deps.py +10 -38
- app/models/schemas.py +0 -1
- app/services/auth_service.py +132 -201
- app/services/supabase/__init__.py +13 -0
- app/services/supabase/client.py +151 -0
- app/services/supabase/repositories.py +255 -0
- app/services/url_shortener_service.py +176 -295
- app/services/vector_store_service.py +35 -49
- pyproject.toml +1 -0
- requirements.txt +1 -1
.gitignore
CHANGED
|
@@ -88,6 +88,7 @@ venv/
|
|
| 88 |
ENV/
|
| 89 |
env.bak/
|
| 90 |
venv.bak/
|
|
|
|
| 91 |
|
| 92 |
.spyderproject
|
| 93 |
.spyproject
|
|
@@ -131,4 +132,6 @@ test_vector_store_async.py
|
|
| 131 |
deploy_sdk.py
|
| 132 |
tests
|
| 133 |
deploy_hf.py
|
| 134 |
-
.mimocode
|
|
|
|
|
|
|
|
|
| 88 |
ENV/
|
| 89 |
env.bak/
|
| 90 |
venv.bak/
|
| 91 |
+
.env.local
|
| 92 |
|
| 93 |
.spyderproject
|
| 94 |
.spyproject
|
|
|
|
| 132 |
deploy_sdk.py
|
| 133 |
tests
|
| 134 |
deploy_hf.py
|
| 135 |
+
.mimocode
|
| 136 |
+
|
| 137 |
+
ddl
|
app/api/server.py
CHANGED
|
@@ -15,7 +15,6 @@ from app.core.database import pool_manager
|
|
| 15 |
from app.core.logger import get_logger
|
| 16 |
from app.core.redis_client import close_redis, create_redis_client
|
| 17 |
from app.core.scripts import load_scripts
|
| 18 |
-
from app.core.tidb_manager import TiDBWriteError
|
| 19 |
from app.services.embeddings_service import EmbeddingService
|
| 20 |
from app.services.vector_store_service import VectorStoreService
|
| 21 |
|
|
@@ -44,18 +43,15 @@ async def _self_ping():
|
|
| 44 |
|
| 45 |
@asynccontextmanager
|
| 46 |
async def lifespan(app: FastAPI):
|
| 47 |
-
if not _settings.
|
| 48 |
_logger.error(
|
| 49 |
-
"
|
| 50 |
-
"
|
| 51 |
)
|
| 52 |
else:
|
| 53 |
-
_logger.info(
|
| 54 |
-
"Initializing TiDB databases across %d instance(s)...",
|
| 55 |
-
len(_settings.tidb_instance_list),
|
| 56 |
-
)
|
| 57 |
await init_auth_db()
|
| 58 |
-
_logger.info("Authentication database initialized
|
| 59 |
|
| 60 |
_logger.info("Initializing vector store database...")
|
| 61 |
from app.core.vector_store.deps import init_vector_store_db
|
|
@@ -87,11 +83,11 @@ async def lifespan(app: FastAPI):
|
|
| 87 |
await close_redis(redis)
|
| 88 |
await _vector_store_service.close_all()
|
| 89 |
await pool_manager.close_all()
|
| 90 |
-
from app.
|
| 91 |
-
|
| 92 |
-
if
|
| 93 |
-
await
|
| 94 |
-
_logger.info("
|
| 95 |
|
| 96 |
|
| 97 |
def create_application() -> FastAPI:
|
|
@@ -121,18 +117,6 @@ def create_application() -> FastAPI:
|
|
| 121 |
allow_headers=["*"],
|
| 122 |
)
|
| 123 |
|
| 124 |
-
@app.exception_handler(TiDBWriteError)
|
| 125 |
-
async def tidb_write_error_handler(request: Request, exc: TiDBWriteError):
|
| 126 |
-
from starlette.responses import JSONResponse
|
| 127 |
-
return JSONResponse(
|
| 128 |
-
status_code=503,
|
| 129 |
-
content={
|
| 130 |
-
"success": False,
|
| 131 |
-
"detail": "Failed to process request due to database storage limit. "
|
| 132 |
-
"Please try again later or contact support.",
|
| 133 |
-
},
|
| 134 |
-
)
|
| 135 |
-
|
| 136 |
@app.middleware("http")
|
| 137 |
async def maintenance_middleware(request: Request, call_next):
|
| 138 |
if is_maintenance():
|
|
|
|
| 15 |
from app.core.logger import get_logger
|
| 16 |
from app.core.redis_client import close_redis, create_redis_client
|
| 17 |
from app.core.scripts import load_scripts
|
|
|
|
| 18 |
from app.services.embeddings_service import EmbeddingService
|
| 19 |
from app.services.vector_store_service import VectorStoreService
|
| 20 |
|
|
|
|
| 43 |
|
| 44 |
@asynccontextmanager
|
| 45 |
async def lifespan(app: FastAPI):
|
| 46 |
+
if not _settings.supabase_url or not _settings.supabase_service_role_key:
|
| 47 |
_logger.error(
|
| 48 |
+
"Supabase not configured! Set SUPABASE_URL and "
|
| 49 |
+
"SUPABASE_SERVICE_ROLE_KEY environment variables."
|
| 50 |
)
|
| 51 |
else:
|
| 52 |
+
_logger.info("Initializing Supabase databases...")
|
|
|
|
|
|
|
|
|
|
| 53 |
await init_auth_db()
|
| 54 |
+
_logger.info("Authentication database initialized via Supabase")
|
| 55 |
|
| 56 |
_logger.info("Initializing vector store database...")
|
| 57 |
from app.core.vector_store.deps import init_vector_store_db
|
|
|
|
| 83 |
await close_redis(redis)
|
| 84 |
await _vector_store_service.close_all()
|
| 85 |
await pool_manager.close_all()
|
| 86 |
+
from app.services.supabase import get_supabase_client
|
| 87 |
+
client = get_supabase_client()
|
| 88 |
+
if client:
|
| 89 |
+
await client.close()
|
| 90 |
+
_logger.info("Supabase client closed")
|
| 91 |
|
| 92 |
|
| 93 |
def create_application() -> FastAPI:
|
|
|
|
| 117 |
allow_headers=["*"],
|
| 118 |
)
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
@app.middleware("http")
|
| 121 |
async def maintenance_middleware(request: Request, call_next):
|
| 122 |
if is_maintenance():
|
app/api/v1/auth.py
CHANGED
|
@@ -5,7 +5,7 @@ from typing import Annotated
|
|
| 5 |
from fastapi import APIRouter, Depends, Request, status
|
| 6 |
|
| 7 |
from app.config import get_settings
|
| 8 |
-
from app.core.auth.deps import get_current_user, get_db
|
| 9 |
from app.core.auth.models import User
|
| 10 |
from app.core.auth.schemas import (
|
| 11 |
ChangePasswordSchema,
|
|
@@ -24,140 +24,120 @@ from app.core.auth.schemas import (
|
|
| 24 |
UserSchemaField,
|
| 25 |
UserSchemaResponse,
|
| 26 |
)
|
| 27 |
-
from app.
|
| 28 |
from app.services.auth_service import AuthService
|
| 29 |
|
| 30 |
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
| 31 |
_settings = get_settings()
|
| 32 |
|
| 33 |
|
| 34 |
-
def _build(data, application_id=None):
|
| 35 |
-
result = {"success": True, "data": data}
|
| 36 |
-
if application_id:
|
| 37 |
-
result["application_id"] = application_id
|
| 38 |
-
return result
|
| 39 |
-
|
| 40 |
-
|
| 41 |
@router.post("/register", response_model=ProfileResponse, status_code=status.HTTP_201_CREATED)
|
| 42 |
async def register(
|
| 43 |
schema: RegisterSchema,
|
| 44 |
-
db: Annotated[
|
| 45 |
-
_: Annotated[bool, Depends(require_application_id)],
|
| 46 |
):
|
| 47 |
user = await AuthService.register(db, schema)
|
| 48 |
profile = await AuthService.user_to_profile(db, user)
|
| 49 |
-
return
|
| 50 |
|
| 51 |
|
| 52 |
@router.post("/login", response_model=TokenResponse)
|
| 53 |
async def login(
|
| 54 |
schema: LoginSchema,
|
| 55 |
request: Request,
|
| 56 |
-
db: Annotated[
|
| 57 |
-
_: Annotated[bool, Depends(require_application_id)],
|
| 58 |
):
|
| 59 |
tokens = await AuthService.login(db, request, schema)
|
| 60 |
-
return
|
| 61 |
|
| 62 |
|
| 63 |
@router.post("/refresh", response_model=TokenResponse)
|
| 64 |
async def refresh_token(
|
| 65 |
schema: TokenRefreshSchema,
|
| 66 |
-
db: Annotated[
|
| 67 |
-
_: Annotated[bool, Depends(require_application_id)],
|
| 68 |
):
|
| 69 |
tokens = await AuthService.refresh(db, schema.refresh_token)
|
| 70 |
-
return
|
| 71 |
|
| 72 |
|
| 73 |
@router.post("/logout", response_model=MessageDataResponse)
|
| 74 |
async def logout(
|
| 75 |
schema: TokenRefreshSchema,
|
| 76 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 77 |
-
db: Annotated[
|
| 78 |
-
_: Annotated[bool, Depends(require_application_id)],
|
| 79 |
):
|
| 80 |
await AuthService.logout(db, current_user, schema.refresh_token)
|
| 81 |
-
return
|
| 82 |
|
| 83 |
|
| 84 |
@router.post("/logout-all", response_model=MessageDataResponse)
|
| 85 |
async def logout_all(
|
| 86 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 87 |
-
db: Annotated[
|
| 88 |
-
_: Annotated[bool, Depends(require_application_id)],
|
| 89 |
):
|
| 90 |
await AuthService.logout_all(db, current_user)
|
| 91 |
-
return
|
| 92 |
|
| 93 |
|
| 94 |
@router.post("/forgot-password", response_model=MessageDataResponse)
|
| 95 |
async def forgot_password(
|
| 96 |
schema: ForgotPasswordSchema,
|
| 97 |
-
db: Annotated[
|
| 98 |
-
_: Annotated[bool, Depends(require_application_id)],
|
| 99 |
):
|
| 100 |
await AuthService.forgot_password(db, schema)
|
| 101 |
-
return
|
| 102 |
|
| 103 |
|
| 104 |
@router.post("/reset-password", response_model=MessageDataResponse)
|
| 105 |
async def reset_password(
|
| 106 |
schema: ResetPasswordSchema,
|
| 107 |
-
db: Annotated[
|
| 108 |
-
_: Annotated[bool, Depends(require_application_id)],
|
| 109 |
):
|
| 110 |
await AuthService.reset_password(db, schema)
|
| 111 |
-
return
|
| 112 |
|
| 113 |
|
| 114 |
@router.post("/change-password", response_model=MessageDataResponse)
|
| 115 |
async def change_password(
|
| 116 |
schema: ChangePasswordSchema,
|
| 117 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 118 |
-
db: Annotated[
|
| 119 |
-
_: Annotated[bool, Depends(require_application_id)],
|
| 120 |
):
|
| 121 |
await AuthService.change_password(db, current_user, schema)
|
| 122 |
-
return
|
| 123 |
|
| 124 |
|
| 125 |
@router.get("/me", response_model=ProfileResponse)
|
| 126 |
async def get_me(
|
| 127 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 128 |
-
db: Annotated[
|
| 129 |
-
_: Annotated[bool, Depends(require_application_id)],
|
| 130 |
):
|
| 131 |
profile = await AuthService.user_to_profile(db, current_user)
|
| 132 |
-
return
|
| 133 |
|
| 134 |
|
| 135 |
@router.patch("/me", response_model=ProfileResponse)
|
| 136 |
async def update_me(
|
| 137 |
schema: UpdateProfileSchema,
|
| 138 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 139 |
-
db: Annotated[
|
| 140 |
-
_: Annotated[bool, Depends(require_application_id)],
|
| 141 |
):
|
| 142 |
user = await AuthService.update_profile(db, current_user, schema)
|
| 143 |
profile = await AuthService.user_to_profile(db, user)
|
| 144 |
-
return
|
| 145 |
|
| 146 |
|
| 147 |
@router.delete("/me", response_model=MessageDataResponse)
|
| 148 |
async def delete_me(
|
| 149 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 150 |
-
db: Annotated[
|
| 151 |
-
_: Annotated[bool, Depends(require_application_id)],
|
| 152 |
):
|
| 153 |
await AuthService.soft_delete(db, current_user)
|
| 154 |
-
return
|
| 155 |
|
| 156 |
|
| 157 |
@router.get("/schema", response_model=SchemaResponse)
|
| 158 |
-
async def get_user_schema(
|
| 159 |
-
_: Annotated[bool, Depends(require_application_id)],
|
| 160 |
-
):
|
| 161 |
columns = [
|
| 162 |
UserSchemaField(field="id", type="string (UUID)", required=True, description="Unique user identifier", constraints="Auto-generated"),
|
| 163 |
UserSchemaField(field="email", type="string", required=True, description="User email address", constraints="Unique, max 255 chars"),
|
|
@@ -176,25 +156,23 @@ async def get_user_schema(
|
|
| 176 |
UserSchemaField(field="deleted_at", type="datetime (ISO 8601)", required=False, description="Soft delete timestamp", constraints="Nullable"),
|
| 177 |
UserSchemaField(field="roles", type="array[string]", required=False, description="Assigned role names", constraints="Via user_roles association table"),
|
| 178 |
]
|
| 179 |
-
return
|
| 180 |
|
| 181 |
|
| 182 |
@router.get("/sessions", response_model=SessionListResponse)
|
| 183 |
async def list_sessions(
|
| 184 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 185 |
-
db: Annotated[
|
| 186 |
-
_: Annotated[bool, Depends(require_application_id)],
|
| 187 |
):
|
| 188 |
sessions = await AuthService.list_sessions(db, current_user)
|
| 189 |
-
return
|
| 190 |
|
| 191 |
|
| 192 |
@router.delete("/sessions/{session_id}", response_model=MessageDataResponse)
|
| 193 |
async def revoke_session(
|
| 194 |
session_id: str,
|
| 195 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 196 |
-
db: Annotated[
|
| 197 |
-
_: Annotated[bool, Depends(require_application_id)],
|
| 198 |
):
|
| 199 |
await AuthService.revoke_session(db, current_user, session_id)
|
| 200 |
-
return
|
|
|
|
| 5 |
from fastapi import APIRouter, Depends, Request, status
|
| 6 |
|
| 7 |
from app.config import get_settings
|
| 8 |
+
from app.core.auth.deps import get_current_user, get_db
|
| 9 |
from app.core.auth.models import User
|
| 10 |
from app.core.auth.schemas import (
|
| 11 |
ChangePasswordSchema,
|
|
|
|
| 24 |
UserSchemaField,
|
| 25 |
UserSchemaResponse,
|
| 26 |
)
|
| 27 |
+
from app.services.supabase import SupabaseClient
|
| 28 |
from app.services.auth_service import AuthService
|
| 29 |
|
| 30 |
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
| 31 |
_settings = get_settings()
|
| 32 |
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
@router.post("/register", response_model=ProfileResponse, status_code=status.HTTP_201_CREATED)
|
| 35 |
async def register(
|
| 36 |
schema: RegisterSchema,
|
| 37 |
+
db: Annotated[SupabaseClient, Depends(get_db)],
|
|
|
|
| 38 |
):
|
| 39 |
user = await AuthService.register(db, schema)
|
| 40 |
profile = await AuthService.user_to_profile(db, user)
|
| 41 |
+
return {"success": True, "data": profile.model_dump()}
|
| 42 |
|
| 43 |
|
| 44 |
@router.post("/login", response_model=TokenResponse)
|
| 45 |
async def login(
|
| 46 |
schema: LoginSchema,
|
| 47 |
request: Request,
|
| 48 |
+
db: Annotated[SupabaseClient, Depends(get_db)],
|
|
|
|
| 49 |
):
|
| 50 |
tokens = await AuthService.login(db, request, schema)
|
| 51 |
+
return {"success": True, "data": tokens.model_dump()}
|
| 52 |
|
| 53 |
|
| 54 |
@router.post("/refresh", response_model=TokenResponse)
|
| 55 |
async def refresh_token(
|
| 56 |
schema: TokenRefreshSchema,
|
| 57 |
+
db: Annotated[SupabaseClient, Depends(get_db)],
|
|
|
|
| 58 |
):
|
| 59 |
tokens = await AuthService.refresh(db, schema.refresh_token)
|
| 60 |
+
return {"success": True, "data": tokens.model_dump()}
|
| 61 |
|
| 62 |
|
| 63 |
@router.post("/logout", response_model=MessageDataResponse)
|
| 64 |
async def logout(
|
| 65 |
schema: TokenRefreshSchema,
|
| 66 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 67 |
+
db: Annotated[SupabaseClient, Depends(get_db)],
|
|
|
|
| 68 |
):
|
| 69 |
await AuthService.logout(db, current_user, schema.refresh_token)
|
| 70 |
+
return {"success": True, "data": {"message": "Logged out successfully"}}
|
| 71 |
|
| 72 |
|
| 73 |
@router.post("/logout-all", response_model=MessageDataResponse)
|
| 74 |
async def logout_all(
|
| 75 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 76 |
+
db: Annotated[SupabaseClient, Depends(get_db)],
|
|
|
|
| 77 |
):
|
| 78 |
await AuthService.logout_all(db, current_user)
|
| 79 |
+
return {"success": True, "data": {"message": "All sessions revoked"}}
|
| 80 |
|
| 81 |
|
| 82 |
@router.post("/forgot-password", response_model=MessageDataResponse)
|
| 83 |
async def forgot_password(
|
| 84 |
schema: ForgotPasswordSchema,
|
| 85 |
+
db: Annotated[SupabaseClient, Depends(get_db)],
|
|
|
|
| 86 |
):
|
| 87 |
await AuthService.forgot_password(db, schema)
|
| 88 |
+
return {"success": True, "data": {"message": "If that email exists, a password reset link has been sent."}}
|
| 89 |
|
| 90 |
|
| 91 |
@router.post("/reset-password", response_model=MessageDataResponse)
|
| 92 |
async def reset_password(
|
| 93 |
schema: ResetPasswordSchema,
|
| 94 |
+
db: Annotated[SupabaseClient, Depends(get_db)],
|
|
|
|
| 95 |
):
|
| 96 |
await AuthService.reset_password(db, schema)
|
| 97 |
+
return {"success": True, "data": {"message": "Password reset successfully"}}
|
| 98 |
|
| 99 |
|
| 100 |
@router.post("/change-password", response_model=MessageDataResponse)
|
| 101 |
async def change_password(
|
| 102 |
schema: ChangePasswordSchema,
|
| 103 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 104 |
+
db: Annotated[SupabaseClient, Depends(get_db)],
|
|
|
|
| 105 |
):
|
| 106 |
await AuthService.change_password(db, current_user, schema)
|
| 107 |
+
return {"success": True, "data": {"message": "Password changed successfully"}}
|
| 108 |
|
| 109 |
|
| 110 |
@router.get("/me", response_model=ProfileResponse)
|
| 111 |
async def get_me(
|
| 112 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 113 |
+
db: Annotated[SupabaseClient, Depends(get_db)],
|
|
|
|
| 114 |
):
|
| 115 |
profile = await AuthService.user_to_profile(db, current_user)
|
| 116 |
+
return {"success": True, "data": profile.model_dump()}
|
| 117 |
|
| 118 |
|
| 119 |
@router.patch("/me", response_model=ProfileResponse)
|
| 120 |
async def update_me(
|
| 121 |
schema: UpdateProfileSchema,
|
| 122 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 123 |
+
db: Annotated[SupabaseClient, Depends(get_db)],
|
|
|
|
| 124 |
):
|
| 125 |
user = await AuthService.update_profile(db, current_user, schema)
|
| 126 |
profile = await AuthService.user_to_profile(db, user)
|
| 127 |
+
return {"success": True, "data": profile.model_dump()}
|
| 128 |
|
| 129 |
|
| 130 |
@router.delete("/me", response_model=MessageDataResponse)
|
| 131 |
async def delete_me(
|
| 132 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 133 |
+
db: Annotated[SupabaseClient, Depends(get_db)],
|
|
|
|
| 134 |
):
|
| 135 |
await AuthService.soft_delete(db, current_user)
|
| 136 |
+
return {"success": True, "data": {"message": "Account deleted successfully"}}
|
| 137 |
|
| 138 |
|
| 139 |
@router.get("/schema", response_model=SchemaResponse)
|
| 140 |
+
async def get_user_schema():
|
|
|
|
|
|
|
| 141 |
columns = [
|
| 142 |
UserSchemaField(field="id", type="string (UUID)", required=True, description="Unique user identifier", constraints="Auto-generated"),
|
| 143 |
UserSchemaField(field="email", type="string", required=True, description="User email address", constraints="Unique, max 255 chars"),
|
|
|
|
| 156 |
UserSchemaField(field="deleted_at", type="datetime (ISO 8601)", required=False, description="Soft delete timestamp", constraints="Nullable"),
|
| 157 |
UserSchemaField(field="roles", type="array[string]", required=False, description="Assigned role names", constraints="Via user_roles association table"),
|
| 158 |
]
|
| 159 |
+
return {"success": True, "data": UserSchemaResponse(table_name="users", columns=columns).model_dump()}
|
| 160 |
|
| 161 |
|
| 162 |
@router.get("/sessions", response_model=SessionListResponse)
|
| 163 |
async def list_sessions(
|
| 164 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 165 |
+
db: Annotated[SupabaseClient, Depends(get_db)],
|
|
|
|
| 166 |
):
|
| 167 |
sessions = await AuthService.list_sessions(db, current_user)
|
| 168 |
+
return {"success": True, "data": [SessionOut.model_validate(s).model_dump() for s in sessions]}
|
| 169 |
|
| 170 |
|
| 171 |
@router.delete("/sessions/{session_id}", response_model=MessageDataResponse)
|
| 172 |
async def revoke_session(
|
| 173 |
session_id: str,
|
| 174 |
current_user: Annotated[User, Depends(get_current_user)],
|
| 175 |
+
db: Annotated[SupabaseClient, Depends(get_db)],
|
|
|
|
| 176 |
):
|
| 177 |
await AuthService.revoke_session(db, current_user, session_id)
|
| 178 |
+
return {"success": True, "data": {"message": "Session revoked successfully"}}
|
app/api/v1/url_shortener.py
CHANGED
|
@@ -36,10 +36,6 @@ def _get_service() -> URLShortenerService:
|
|
| 36 |
return _shared_service
|
| 37 |
|
| 38 |
|
| 39 |
-
def _app_id() -> str:
|
| 40 |
-
return _settings.application_id or ""
|
| 41 |
-
|
| 42 |
-
|
| 43 |
# ---------------------------------------------------------------------------
|
| 44 |
# Request / Response models
|
| 45 |
# ---------------------------------------------------------------------------
|
|
@@ -54,7 +50,6 @@ class CreateOwnerResponse(BaseModel):
|
|
| 54 |
owner_id: str
|
| 55 |
api_key: str
|
| 56 |
plan: str
|
| 57 |
-
application_id: str = ""
|
| 58 |
|
| 59 |
|
| 60 |
class ShortenRequest(BaseModel):
|
|
@@ -94,7 +89,6 @@ class ShortLinkResponse(BaseModel):
|
|
| 94 |
custom_domain: Optional[str] = None
|
| 95 |
fallback_url: Optional[str] = None
|
| 96 |
webhook_url: Optional[str] = None
|
| 97 |
-
application_id: str = ""
|
| 98 |
|
| 99 |
|
| 100 |
class UpdateLinkRequest(BaseModel):
|
|
@@ -113,7 +107,6 @@ class UpdateLinkRequest(BaseModel):
|
|
| 113 |
class ListLinksResponse(BaseModel):
|
| 114 |
success: bool = True
|
| 115 |
links: List[ShortLinkResponse]
|
| 116 |
-
application_id: str = ""
|
| 117 |
|
| 118 |
|
| 119 |
class StatsResponse(BaseModel):
|
|
@@ -133,7 +126,6 @@ class StatsResponse(BaseModel):
|
|
| 133 |
custom_domain: Optional[str] = None
|
| 134 |
fallback_url: Optional[str] = None
|
| 135 |
webhook_url: Optional[str] = None
|
| 136 |
-
application_id: str = ""
|
| 137 |
|
| 138 |
|
| 139 |
class ClickDetail(BaseModel):
|
|
@@ -150,7 +142,6 @@ class ClickDetail(BaseModel):
|
|
| 150 |
class ClickDetailsResponse(BaseModel):
|
| 151 |
success: bool = True
|
| 152 |
clicks: List[ClickDetail]
|
| 153 |
-
application_id: str = ""
|
| 154 |
|
| 155 |
|
| 156 |
class QRCodeResponse(BaseModel):
|
|
@@ -158,7 +149,6 @@ class QRCodeResponse(BaseModel):
|
|
| 158 |
short_code: str
|
| 159 |
short_url: str
|
| 160 |
qr_image_url: str
|
| 161 |
-
application_id: str = ""
|
| 162 |
|
| 163 |
|
| 164 |
class ErrorResponse(BaseModel):
|
|
@@ -173,7 +163,6 @@ class BulkShortenRequest(BaseModel):
|
|
| 173 |
class BulkShortenResponse(BaseModel):
|
| 174 |
success: bool = True
|
| 175 |
results: List[Dict[str, Any]]
|
| 176 |
-
application_id: str = ""
|
| 177 |
|
| 178 |
|
| 179 |
class OwnerSummaryResponse(BaseModel):
|
|
@@ -185,7 +174,6 @@ class OwnerSummaryResponse(BaseModel):
|
|
| 185 |
active_links: int
|
| 186 |
total_clicks: int
|
| 187 |
created_at: str
|
| 188 |
-
application_id: str = ""
|
| 189 |
|
| 190 |
|
| 191 |
class CampaignCreateRequest(BaseModel):
|
|
@@ -203,13 +191,11 @@ class CampaignResponse(BaseModel):
|
|
| 203 |
link_count: Optional[int] = 0
|
| 204 |
total_clicks: Optional[int] = 0
|
| 205 |
analytics: Optional[Dict[str, Any]] = None
|
| 206 |
-
application_id: str = ""
|
| 207 |
|
| 208 |
|
| 209 |
class CampaignListResponse(BaseModel):
|
| 210 |
success: bool = True
|
| 211 |
campaigns: List[CampaignResponse]
|
| 212 |
-
application_id: str = ""
|
| 213 |
|
| 214 |
|
| 215 |
class HealthCheckResponse(BaseModel):
|
|
@@ -219,7 +205,6 @@ class HealthCheckResponse(BaseModel):
|
|
| 219 |
reachable: bool
|
| 220 |
status_code: Optional[int] = None
|
| 221 |
error: Optional[str] = None
|
| 222 |
-
application_id: str = ""
|
| 223 |
|
| 224 |
|
| 225 |
# ---------------------------------------------------------------------------
|
|
@@ -251,7 +236,6 @@ def _build_link_response(link, service) -> ShortLinkResponse:
|
|
| 251 |
custom_domain=link.custom_domain,
|
| 252 |
fallback_url=link.fallback_url,
|
| 253 |
webhook_url=link.webhook_url,
|
| 254 |
-
application_id=_app_id(),
|
| 255 |
)
|
| 256 |
|
| 257 |
|
|
@@ -271,7 +255,6 @@ def create_owner(body: CreateOwnerRequest):
|
|
| 271 |
owner_id=result["owner_id"],
|
| 272 |
api_key=result["api_key"],
|
| 273 |
plan=body.plan or "free",
|
| 274 |
-
application_id=_app_id(),
|
| 275 |
)
|
| 276 |
|
| 277 |
|
|
@@ -283,7 +266,7 @@ def update_plan(plan: str = Query(..., description="free, pro, or enterprise"),
|
|
| 283 |
owner_id: str = Depends(_auth_owner)):
|
| 284 |
service = _get_service()
|
| 285 |
try:
|
| 286 |
-
return {"success": True,
|
| 287 |
except ValidationError as e:
|
| 288 |
raise HTTPException(status_code=400, detail={"success": False, "error": str(e)})
|
| 289 |
|
|
@@ -298,7 +281,7 @@ def owner_summary(owner_id: str = Depends(_auth_owner)):
|
|
| 298 |
data = service.owner_summary(owner_id)
|
| 299 |
if not data:
|
| 300 |
raise HTTPException(status_code=404, detail={"success": False, "error": "Owner not found"})
|
| 301 |
-
return OwnerSummaryResponse(
|
| 302 |
|
| 303 |
|
| 304 |
# ---------------------------------------------------------------------------
|
|
@@ -361,7 +344,7 @@ def bulk_shorten(body: BulkShortenRequest, owner_id: str = Depends(_auth_owner))
|
|
| 361 |
raise HTTPException(status_code=400, detail={"success": False, "error": "Bulk limit is 100 items per request"})
|
| 362 |
service = _get_service()
|
| 363 |
results = service.bulk_shorten(owner_id, body.items)
|
| 364 |
-
return BulkShortenResponse(results=results,
|
| 365 |
|
| 366 |
|
| 367 |
@router.get(
|
|
@@ -373,7 +356,7 @@ def list_links(owner_id: str = Depends(_auth_owner)):
|
|
| 373 |
service = _get_service()
|
| 374 |
links = service.list_links(owner_id)
|
| 375 |
items = [_build_link_response(link, service) for link in links]
|
| 376 |
-
return ListLinksResponse(links=items,
|
| 377 |
|
| 378 |
|
| 379 |
@router.get(
|
|
@@ -400,7 +383,6 @@ def create_campaign(body: CampaignCreateRequest, owner_id: str = Depends(_auth_o
|
|
| 400 |
campaign_id=result["campaign_id"],
|
| 401 |
name=result["name"],
|
| 402 |
description=result.get("description"),
|
| 403 |
-
application_id=_app_id(),
|
| 404 |
)
|
| 405 |
except PlanLimitExceededError as e:
|
| 406 |
raise HTTPException(status_code=402, detail={"success": False, "error": str(e), "code": "plan_limit"})
|
|
@@ -416,8 +398,8 @@ def create_campaign(body: CampaignCreateRequest, owner_id: str = Depends(_auth_o
|
|
| 416 |
def list_campaigns(owner_id: str = Depends(_auth_owner)):
|
| 417 |
service = _get_service()
|
| 418 |
campaigns = service.list_campaigns(owner_id)
|
| 419 |
-
items = [CampaignResponse(**c,
|
| 420 |
-
return CampaignListResponse(campaigns=items,
|
| 421 |
|
| 422 |
|
| 423 |
@router.get(
|
|
@@ -432,7 +414,7 @@ def get_campaign(
|
|
| 432 |
service = _get_service()
|
| 433 |
try:
|
| 434 |
data = service.get_campaign(campaign_id, owner_id)
|
| 435 |
-
return CampaignResponse(**data,
|
| 436 |
except LinkNotFoundError as e:
|
| 437 |
raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
|
| 438 |
except AuthorizationError as e:
|
|
@@ -452,7 +434,7 @@ def list_campaign_links(
|
|
| 452 |
try:
|
| 453 |
links = service.list_campaign_links(campaign_id, owner_id)
|
| 454 |
items = [_build_link_response(link, service) for link in links]
|
| 455 |
-
return ListLinksResponse(links=items,
|
| 456 |
except LinkNotFoundError as e:
|
| 457 |
raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
|
| 458 |
except AuthorizationError as e:
|
|
@@ -470,7 +452,7 @@ def deactivate_campaign(
|
|
| 470 |
service = _get_service()
|
| 471 |
try:
|
| 472 |
service.deactivate_campaign(campaign_id, owner_id)
|
| 473 |
-
return {"success": True, "message": f"Campaign '{campaign_id}' deactivated"
|
| 474 |
except LinkNotFoundError as e:
|
| 475 |
raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
|
| 476 |
except AuthorizationError as e:
|
|
@@ -545,7 +527,7 @@ def deactivate_link(
|
|
| 545 |
service = _get_service()
|
| 546 |
try:
|
| 547 |
service.deactivate_link(short_code, owner_id)
|
| 548 |
-
return {"success": True, "message": f"Link '{short_code}' deactivated"
|
| 549 |
except LinkNotFoundError as e:
|
| 550 |
raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
|
| 551 |
except AuthorizationError as e:
|
|
@@ -580,7 +562,6 @@ def get_link_stats(
|
|
| 580 |
custom_domain=stats.get("custom_domain"),
|
| 581 |
fallback_url=stats.get("fallback_url"),
|
| 582 |
webhook_url=stats.get("webhook_url"),
|
| 583 |
-
application_id=_app_id(),
|
| 584 |
)
|
| 585 |
except LinkNotFoundError as e:
|
| 586 |
raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
|
|
@@ -601,7 +582,7 @@ def get_click_details(
|
|
| 601 |
service = _get_service()
|
| 602 |
try:
|
| 603 |
clicks = service.get_click_details(short_code, owner_id, limit)
|
| 604 |
-
return ClickDetailsResponse(clicks=[ClickDetail(**c) for c in clicks],
|
| 605 |
except LinkNotFoundError as e:
|
| 606 |
raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
|
| 607 |
except AuthorizationError as e:
|
|
@@ -619,7 +600,7 @@ def get_qr_code(
|
|
| 619 |
service = _get_service()
|
| 620 |
try:
|
| 621 |
data = service.get_qr_code(short_code)
|
| 622 |
-
return QRCodeResponse(**data,
|
| 623 |
except LinkNotFoundError as e:
|
| 624 |
raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
|
| 625 |
|
|
@@ -636,7 +617,7 @@ def link_health(
|
|
| 636 |
service = _get_service()
|
| 637 |
try:
|
| 638 |
result = service.check_link_health(short_code, owner_id)
|
| 639 |
-
return HealthCheckResponse(**result,
|
| 640 |
except LinkNotFoundError as e:
|
| 641 |
raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
|
| 642 |
except AuthorizationError as e:
|
|
|
|
| 36 |
return _shared_service
|
| 37 |
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
# ---------------------------------------------------------------------------
|
| 40 |
# Request / Response models
|
| 41 |
# ---------------------------------------------------------------------------
|
|
|
|
| 50 |
owner_id: str
|
| 51 |
api_key: str
|
| 52 |
plan: str
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
class ShortenRequest(BaseModel):
|
|
|
|
| 89 |
custom_domain: Optional[str] = None
|
| 90 |
fallback_url: Optional[str] = None
|
| 91 |
webhook_url: Optional[str] = None
|
|
|
|
| 92 |
|
| 93 |
|
| 94 |
class UpdateLinkRequest(BaseModel):
|
|
|
|
| 107 |
class ListLinksResponse(BaseModel):
|
| 108 |
success: bool = True
|
| 109 |
links: List[ShortLinkResponse]
|
|
|
|
| 110 |
|
| 111 |
|
| 112 |
class StatsResponse(BaseModel):
|
|
|
|
| 126 |
custom_domain: Optional[str] = None
|
| 127 |
fallback_url: Optional[str] = None
|
| 128 |
webhook_url: Optional[str] = None
|
|
|
|
| 129 |
|
| 130 |
|
| 131 |
class ClickDetail(BaseModel):
|
|
|
|
| 142 |
class ClickDetailsResponse(BaseModel):
|
| 143 |
success: bool = True
|
| 144 |
clicks: List[ClickDetail]
|
|
|
|
| 145 |
|
| 146 |
|
| 147 |
class QRCodeResponse(BaseModel):
|
|
|
|
| 149 |
short_code: str
|
| 150 |
short_url: str
|
| 151 |
qr_image_url: str
|
|
|
|
| 152 |
|
| 153 |
|
| 154 |
class ErrorResponse(BaseModel):
|
|
|
|
| 163 |
class BulkShortenResponse(BaseModel):
|
| 164 |
success: bool = True
|
| 165 |
results: List[Dict[str, Any]]
|
|
|
|
| 166 |
|
| 167 |
|
| 168 |
class OwnerSummaryResponse(BaseModel):
|
|
|
|
| 174 |
active_links: int
|
| 175 |
total_clicks: int
|
| 176 |
created_at: str
|
|
|
|
| 177 |
|
| 178 |
|
| 179 |
class CampaignCreateRequest(BaseModel):
|
|
|
|
| 191 |
link_count: Optional[int] = 0
|
| 192 |
total_clicks: Optional[int] = 0
|
| 193 |
analytics: Optional[Dict[str, Any]] = None
|
|
|
|
| 194 |
|
| 195 |
|
| 196 |
class CampaignListResponse(BaseModel):
|
| 197 |
success: bool = True
|
| 198 |
campaigns: List[CampaignResponse]
|
|
|
|
| 199 |
|
| 200 |
|
| 201 |
class HealthCheckResponse(BaseModel):
|
|
|
|
| 205 |
reachable: bool
|
| 206 |
status_code: Optional[int] = None
|
| 207 |
error: Optional[str] = None
|
|
|
|
| 208 |
|
| 209 |
|
| 210 |
# ---------------------------------------------------------------------------
|
|
|
|
| 236 |
custom_domain=link.custom_domain,
|
| 237 |
fallback_url=link.fallback_url,
|
| 238 |
webhook_url=link.webhook_url,
|
|
|
|
| 239 |
)
|
| 240 |
|
| 241 |
|
|
|
|
| 255 |
owner_id=result["owner_id"],
|
| 256 |
api_key=result["api_key"],
|
| 257 |
plan=body.plan or "free",
|
|
|
|
| 258 |
)
|
| 259 |
|
| 260 |
|
|
|
|
| 266 |
owner_id: str = Depends(_auth_owner)):
|
| 267 |
service = _get_service()
|
| 268 |
try:
|
| 269 |
+
return {"success": True, **service.update_owner_plan(owner_id, plan)}
|
| 270 |
except ValidationError as e:
|
| 271 |
raise HTTPException(status_code=400, detail={"success": False, "error": str(e)})
|
| 272 |
|
|
|
|
| 281 |
data = service.owner_summary(owner_id)
|
| 282 |
if not data:
|
| 283 |
raise HTTPException(status_code=404, detail={"success": False, "error": "Owner not found"})
|
| 284 |
+
return OwnerSummaryResponse(**data)
|
| 285 |
|
| 286 |
|
| 287 |
# ---------------------------------------------------------------------------
|
|
|
|
| 344 |
raise HTTPException(status_code=400, detail={"success": False, "error": "Bulk limit is 100 items per request"})
|
| 345 |
service = _get_service()
|
| 346 |
results = service.bulk_shorten(owner_id, body.items)
|
| 347 |
+
return BulkShortenResponse(results=results, )
|
| 348 |
|
| 349 |
|
| 350 |
@router.get(
|
|
|
|
| 356 |
service = _get_service()
|
| 357 |
links = service.list_links(owner_id)
|
| 358 |
items = [_build_link_response(link, service) for link in links]
|
| 359 |
+
return ListLinksResponse(links=items, )
|
| 360 |
|
| 361 |
|
| 362 |
@router.get(
|
|
|
|
| 383 |
campaign_id=result["campaign_id"],
|
| 384 |
name=result["name"],
|
| 385 |
description=result.get("description"),
|
|
|
|
| 386 |
)
|
| 387 |
except PlanLimitExceededError as e:
|
| 388 |
raise HTTPException(status_code=402, detail={"success": False, "error": str(e), "code": "plan_limit"})
|
|
|
|
| 398 |
def list_campaigns(owner_id: str = Depends(_auth_owner)):
|
| 399 |
service = _get_service()
|
| 400 |
campaigns = service.list_campaigns(owner_id)
|
| 401 |
+
items = [CampaignResponse(**c, ) for c in campaigns]
|
| 402 |
+
return CampaignListResponse(campaigns=items, )
|
| 403 |
|
| 404 |
|
| 405 |
@router.get(
|
|
|
|
| 414 |
service = _get_service()
|
| 415 |
try:
|
| 416 |
data = service.get_campaign(campaign_id, owner_id)
|
| 417 |
+
return CampaignResponse(**data, )
|
| 418 |
except LinkNotFoundError as e:
|
| 419 |
raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
|
| 420 |
except AuthorizationError as e:
|
|
|
|
| 434 |
try:
|
| 435 |
links = service.list_campaign_links(campaign_id, owner_id)
|
| 436 |
items = [_build_link_response(link, service) for link in links]
|
| 437 |
+
return ListLinksResponse(links=items, )
|
| 438 |
except LinkNotFoundError as e:
|
| 439 |
raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
|
| 440 |
except AuthorizationError as e:
|
|
|
|
| 452 |
service = _get_service()
|
| 453 |
try:
|
| 454 |
service.deactivate_campaign(campaign_id, owner_id)
|
| 455 |
+
return {"success": True, "message": f"Campaign '{campaign_id}' deactivated"}
|
| 456 |
except LinkNotFoundError as e:
|
| 457 |
raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
|
| 458 |
except AuthorizationError as e:
|
|
|
|
| 527 |
service = _get_service()
|
| 528 |
try:
|
| 529 |
service.deactivate_link(short_code, owner_id)
|
| 530 |
+
return {"success": True, "message": f"Link '{short_code}' deactivated"}
|
| 531 |
except LinkNotFoundError as e:
|
| 532 |
raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
|
| 533 |
except AuthorizationError as e:
|
|
|
|
| 562 |
custom_domain=stats.get("custom_domain"),
|
| 563 |
fallback_url=stats.get("fallback_url"),
|
| 564 |
webhook_url=stats.get("webhook_url"),
|
|
|
|
| 565 |
)
|
| 566 |
except LinkNotFoundError as e:
|
| 567 |
raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
|
|
|
|
| 582 |
service = _get_service()
|
| 583 |
try:
|
| 584 |
clicks = service.get_click_details(short_code, owner_id, limit)
|
| 585 |
+
return ClickDetailsResponse(clicks=[ClickDetail(**c) for c in clicks], )
|
| 586 |
except LinkNotFoundError as e:
|
| 587 |
raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
|
| 588 |
except AuthorizationError as e:
|
|
|
|
| 600 |
service = _get_service()
|
| 601 |
try:
|
| 602 |
data = service.get_qr_code(short_code)
|
| 603 |
+
return QRCodeResponse(**data, )
|
| 604 |
except LinkNotFoundError as e:
|
| 605 |
raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
|
| 606 |
|
|
|
|
| 617 |
service = _get_service()
|
| 618 |
try:
|
| 619 |
result = service.check_link_health(short_code, owner_id)
|
| 620 |
+
return HealthCheckResponse(**result, )
|
| 621 |
except LinkNotFoundError as e:
|
| 622 |
raise HTTPException(status_code=404, detail={"success": False, "error": str(e)})
|
| 623 |
except AuthorizationError as e:
|
app/api/v1/vector_stores.py
CHANGED
|
@@ -64,7 +64,7 @@ async def create_vector_store(
|
|
| 64 |
token: str = Depends(require_auth),
|
| 65 |
vector_store_service: VectorStoreService = Depends(get_vector_store_service),
|
| 66 |
) -> VectorStoreResponse:
|
| 67 |
-
store_id
|
| 68 |
name=body.name,
|
| 69 |
description=body.description or "",
|
| 70 |
metadata=body.metadata,
|
|
@@ -73,7 +73,6 @@ async def create_vector_store(
|
|
| 73 |
return VectorStoreResponse(
|
| 74 |
success=True,
|
| 75 |
vector_store_id=store_id,
|
| 76 |
-
app_id=app_id,
|
| 77 |
name=body.name,
|
| 78 |
description=body.description,
|
| 79 |
embedding_dimension=stats["embedding_dimension"],
|
|
@@ -102,7 +101,6 @@ async def list_vector_stores(
|
|
| 102 |
stores.append(VectorStoreResponse(
|
| 103 |
success=True,
|
| 104 |
vector_store_id=r.store_id,
|
| 105 |
-
app_id=stats.get("app_id", r.store_id),
|
| 106 |
name=r.name,
|
| 107 |
description=r.description,
|
| 108 |
embedding_dimension=stats.get("embedding_dimension", 0),
|
|
@@ -130,7 +128,6 @@ async def get_vector_store(
|
|
| 130 |
return VectorStoreResponse(
|
| 131 |
success=True,
|
| 132 |
vector_store_id=stats["store_id"],
|
| 133 |
-
app_id=stats["app_id"],
|
| 134 |
name=stats["name"],
|
| 135 |
description=stats["description"],
|
| 136 |
embedding_dimension=stats["embedding_dimension"],
|
|
@@ -159,7 +156,6 @@ async def delete_vector_store(
|
|
| 159 |
return VectorStoreResponse(
|
| 160 |
success=True,
|
| 161 |
vector_store_id=store_id,
|
| 162 |
-
app_id="",
|
| 163 |
name=record.name,
|
| 164 |
document_count=0,
|
| 165 |
embedding_dimension=0,
|
|
|
|
| 64 |
token: str = Depends(require_auth),
|
| 65 |
vector_store_service: VectorStoreService = Depends(get_vector_store_service),
|
| 66 |
) -> VectorStoreResponse:
|
| 67 |
+
store_id = await vector_store_service.create_store(
|
| 68 |
name=body.name,
|
| 69 |
description=body.description or "",
|
| 70 |
metadata=body.metadata,
|
|
|
|
| 73 |
return VectorStoreResponse(
|
| 74 |
success=True,
|
| 75 |
vector_store_id=store_id,
|
|
|
|
| 76 |
name=body.name,
|
| 77 |
description=body.description,
|
| 78 |
embedding_dimension=stats["embedding_dimension"],
|
|
|
|
| 101 |
stores.append(VectorStoreResponse(
|
| 102 |
success=True,
|
| 103 |
vector_store_id=r.store_id,
|
|
|
|
| 104 |
name=r.name,
|
| 105 |
description=r.description,
|
| 106 |
embedding_dimension=stats.get("embedding_dimension", 0),
|
|
|
|
| 128 |
return VectorStoreResponse(
|
| 129 |
success=True,
|
| 130 |
vector_store_id=stats["store_id"],
|
|
|
|
| 131 |
name=stats["name"],
|
| 132 |
description=stats["description"],
|
| 133 |
embedding_dimension=stats["embedding_dimension"],
|
|
|
|
| 156 |
return VectorStoreResponse(
|
| 157 |
success=True,
|
| 158 |
vector_store_id=store_id,
|
|
|
|
| 159 |
name=record.name,
|
| 160 |
document_count=0,
|
| 161 |
embedding_dimension=0,
|
app/config.py
CHANGED
|
@@ -8,7 +8,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
| 8 |
|
| 9 |
class Settings(BaseSettings):
|
| 10 |
model_config = SettingsConfigDict(
|
| 11 |
-
env_file=".env",
|
| 12 |
env_file_encoding="utf-8",
|
| 13 |
extra="ignore",
|
| 14 |
)
|
|
@@ -73,19 +73,16 @@ class Settings(BaseSettings):
|
|
| 73 |
jwt_default_expiry_minutes: int = 30
|
| 74 |
jwt_issuer: str = "all-api-collection"
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
| 78 |
access_token_expire_minutes: int = 15
|
| 79 |
refresh_token_expire_days: int = 7
|
| 80 |
max_login_attempts: int = 5
|
| 81 |
lockout_minutes: int = 15
|
| 82 |
-
application_id: str = ""
|
| 83 |
admin_password: str = ""
|
| 84 |
|
| 85 |
-
@property
|
| 86 |
-
def tidb_instance_list(self) -> list[str]:
|
| 87 |
-
return [u.strip() for u in self.tidb_instances.split(",") if u.strip()]
|
| 88 |
-
|
| 89 |
@property
|
| 90 |
def max_upload_mb(self) -> int:
|
| 91 |
return self.max_upload_bytes // (1024 * 1024)
|
|
|
|
| 8 |
|
| 9 |
class Settings(BaseSettings):
|
| 10 |
model_config = SettingsConfigDict(
|
| 11 |
+
env_file=[".env", ".env.local"],
|
| 12 |
env_file_encoding="utf-8",
|
| 13 |
extra="ignore",
|
| 14 |
)
|
|
|
|
| 73 |
jwt_default_expiry_minutes: int = 30
|
| 74 |
jwt_issuer: str = "all-api-collection"
|
| 75 |
|
| 76 |
+
supabase_url: str = "http://localhost:8000"
|
| 77 |
+
supabase_anon_key: str = ""
|
| 78 |
+
supabase_service_role_key: str = ""
|
| 79 |
+
supabase_schema: str = "public"
|
| 80 |
access_token_expire_minutes: int = 15
|
| 81 |
refresh_token_expire_days: int = 7
|
| 82 |
max_login_attempts: int = 5
|
| 83 |
lockout_minutes: int = 15
|
|
|
|
| 84 |
admin_password: str = ""
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
@property
|
| 87 |
def max_upload_mb(self) -> int:
|
| 88 |
return self.max_upload_bytes // (1024 * 1024)
|
app/core/auth/deps.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import logging
|
| 4 |
from datetime import datetime
|
| 5 |
-
from typing import Annotated, Optional
|
| 6 |
|
| 7 |
import jwt
|
| 8 |
from argon2 import PasswordHasher
|
|
@@ -11,124 +11,28 @@ from fastapi import Depends, HTTPException, Request, status
|
|
| 11 |
from app.config import get_settings
|
| 12 |
from app.core.auth.models import RefreshSession, User, _uuid
|
| 13 |
from app.core.auth.models import _utcnow as _now
|
| 14 |
-
from app.
|
|
|
|
|
|
|
| 15 |
|
| 16 |
logger = logging.getLogger("auth")
|
| 17 |
|
| 18 |
_settings = get_settings()
|
| 19 |
ph = PasswordHasher()
|
| 20 |
|
| 21 |
-
AUTH_TABLES: dict[str, str] = {
|
| 22 |
-
"users": """
|
| 23 |
-
CREATE TABLE IF NOT EXISTS users (
|
| 24 |
-
id VARCHAR(36) PRIMARY KEY,
|
| 25 |
-
email VARCHAR(255) NOT NULL,
|
| 26 |
-
username VARCHAR(50) NULL,
|
| 27 |
-
full_name VARCHAR(255) NULL,
|
| 28 |
-
password_hash TEXT NOT NULL,
|
| 29 |
-
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
| 30 |
-
is_verified TINYINT(1) NOT NULL DEFAULT 0,
|
| 31 |
-
failed_login_attempts INT NOT NULL DEFAULT 0,
|
| 32 |
-
locked_until DATETIME NULL,
|
| 33 |
-
last_login DATETIME NULL,
|
| 34 |
-
password_changed_at DATETIME NULL,
|
| 35 |
-
created_at DATETIME NOT NULL,
|
| 36 |
-
updated_at DATETIME NOT NULL,
|
| 37 |
-
deleted_at DATETIME NULL,
|
| 38 |
-
UNIQUE KEY uk_users_email (email),
|
| 39 |
-
UNIQUE KEY uk_users_username (username),
|
| 40 |
-
INDEX idx_users_email (email),
|
| 41 |
-
INDEX idx_users_username (username)
|
| 42 |
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 43 |
-
""",
|
| 44 |
-
"roles": """
|
| 45 |
-
CREATE TABLE IF NOT EXISTS roles (
|
| 46 |
-
id VARCHAR(36) PRIMARY KEY,
|
| 47 |
-
name VARCHAR(50) NOT NULL,
|
| 48 |
-
description VARCHAR(255) NULL,
|
| 49 |
-
UNIQUE KEY uk_roles_name (name)
|
| 50 |
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 51 |
-
""",
|
| 52 |
-
"permissions": """
|
| 53 |
-
CREATE TABLE IF NOT EXISTS permissions (
|
| 54 |
-
id VARCHAR(36) PRIMARY KEY,
|
| 55 |
-
code VARCHAR(100) NOT NULL,
|
| 56 |
-
description VARCHAR(255) NULL,
|
| 57 |
-
UNIQUE KEY uk_permissions_code (code),
|
| 58 |
-
INDEX idx_permissions_code (code)
|
| 59 |
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 60 |
-
""",
|
| 61 |
-
"user_roles": """
|
| 62 |
-
CREATE TABLE IF NOT EXISTS user_roles (
|
| 63 |
-
user_id VARCHAR(36) NOT NULL,
|
| 64 |
-
role_id VARCHAR(36) NOT NULL,
|
| 65 |
-
PRIMARY KEY (user_id, role_id),
|
| 66 |
-
CONSTRAINT fk_ur_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
| 67 |
-
CONSTRAINT fk_ur_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
|
| 68 |
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 69 |
-
""",
|
| 70 |
-
"role_permissions": """
|
| 71 |
-
CREATE TABLE IF NOT EXISTS role_permissions (
|
| 72 |
-
role_id VARCHAR(36) NOT NULL,
|
| 73 |
-
permission_id VARCHAR(36) NOT NULL,
|
| 74 |
-
PRIMARY KEY (role_id, permission_id),
|
| 75 |
-
CONSTRAINT fk_rp_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
| 76 |
-
CONSTRAINT fk_rp_perm FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
|
| 77 |
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 78 |
-
""",
|
| 79 |
-
"refresh_sessions": """
|
| 80 |
-
CREATE TABLE IF NOT EXISTS refresh_sessions (
|
| 81 |
-
id VARCHAR(36) PRIMARY KEY,
|
| 82 |
-
user_id VARCHAR(36) NOT NULL,
|
| 83 |
-
token_key VARCHAR(64) NOT NULL,
|
| 84 |
-
token_hash VARCHAR(255) NOT NULL,
|
| 85 |
-
device_info VARCHAR(255) NULL,
|
| 86 |
-
ip_address VARCHAR(45) NULL,
|
| 87 |
-
expires_at DATETIME NOT NULL,
|
| 88 |
-
revoked_at DATETIME NULL,
|
| 89 |
-
created_at DATETIME NOT NULL,
|
| 90 |
-
updated_at DATETIME NOT NULL,
|
| 91 |
-
UNIQUE KEY uk_rs_token_key (token_key),
|
| 92 |
-
INDEX idx_rs_user_id (user_id),
|
| 93 |
-
INDEX idx_rs_token_key (token_key),
|
| 94 |
-
CONSTRAINT fk_rs_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
| 95 |
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 96 |
-
""",
|
| 97 |
-
}
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
def _create_manager() -> Optional[TiDBManager]:
|
| 101 |
-
urls = _settings.tidb_instance_list
|
| 102 |
-
if not urls:
|
| 103 |
-
logger.warning("No TiDB instances configured (TIDB_INSTANCES is empty)")
|
| 104 |
-
return None
|
| 105 |
-
import ssl
|
| 106 |
-
if _settings.tidb_ssl:
|
| 107 |
-
ctx = ssl.create_default_context()
|
| 108 |
-
ctx.check_hostname = False
|
| 109 |
-
ctx.verify_mode = ssl.CERT_NONE
|
| 110 |
-
else:
|
| 111 |
-
ctx = None
|
| 112 |
-
mgr = TiDBManager(urls, ssl=ctx)
|
| 113 |
-
return mgr
|
| 114 |
-
|
| 115 |
|
| 116 |
async def init_auth_db():
|
| 117 |
-
|
| 118 |
-
if
|
| 119 |
-
logger.error("Cannot initialize auth DB:
|
| 120 |
return
|
| 121 |
-
|
| 122 |
-
set_tidb_manager(mgr)
|
| 123 |
|
| 124 |
-
|
| 125 |
|
| 126 |
-
|
| 127 |
-
if not
|
| 128 |
-
|
| 129 |
-
admin_role_id = _uuid()
|
| 130 |
-
user_role_id = _uuid()
|
| 131 |
-
perm_ids = {}
|
| 132 |
for code, desc in [
|
| 133 |
("users:read", "Read users"),
|
| 134 |
("users:write", "Modify users"),
|
|
@@ -136,76 +40,63 @@ async def init_auth_db():
|
|
| 136 |
("admin:access", "Access admin panel"),
|
| 137 |
]:
|
| 138 |
pid = _uuid()
|
| 139 |
-
await
|
| 140 |
-
"
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
| 144 |
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
)
|
| 149 |
-
await mgr.execute(
|
| 150 |
-
"INSERT INTO roles (id, name, description) VALUES (%s, %s, %s)",
|
| 151 |
-
(admin_role_id, "Admin", "Administrative access"),
|
| 152 |
-
)
|
| 153 |
-
await mgr.execute(
|
| 154 |
-
"INSERT INTO roles (id, name, description) VALUES (%s, %s, %s)",
|
| 155 |
-
(user_role_id, "User", "Standard user access"),
|
| 156 |
-
)
|
| 157 |
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
)
|
| 164 |
-
for pid in
|
| 165 |
-
await
|
| 166 |
-
|
| 167 |
-
(admin_role_id, pid),
|
| 168 |
-
)
|
| 169 |
-
await mgr.execute(
|
| 170 |
-
"INSERT INTO role_permissions (role_id, permission_id) VALUES (%s, %s)",
|
| 171 |
-
(user_role_id, perms_list[0]),
|
| 172 |
-
)
|
| 173 |
|
| 174 |
admin_email = "admin@example.com"
|
| 175 |
-
|
| 176 |
-
if not
|
| 177 |
admin_password = _settings.admin_password or "Admin123!"
|
| 178 |
admin_id = _uuid()
|
| 179 |
now = _now()
|
| 180 |
-
await
|
| 181 |
-
"
|
| 182 |
-
"
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
|
|
|
| 191 |
|
| 192 |
from app.services.auth_service import AuthService
|
| 193 |
-
await AuthService.cleanup_expired_sessions(
|
| 194 |
|
| 195 |
|
| 196 |
-
async def get_db() ->
|
| 197 |
-
|
| 198 |
-
if
|
| 199 |
raise HTTPException(
|
| 200 |
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 201 |
-
detail="Database not initialized. Configure
|
| 202 |
)
|
| 203 |
-
return
|
| 204 |
|
| 205 |
|
| 206 |
async def get_current_user(
|
| 207 |
request: Request,
|
| 208 |
-
db: Annotated[
|
| 209 |
) -> User:
|
| 210 |
credentials_exception = HTTPException(
|
| 211 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
@@ -228,51 +119,22 @@ async def get_current_user(
|
|
| 228 |
except jwt.PyJWTError:
|
| 229 |
raise credentials_exception
|
| 230 |
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
)
|
| 235 |
-
if row is None:
|
| 236 |
-
raise credentials_exception
|
| 237 |
-
user = _row_to_user(row)
|
| 238 |
-
if not user.is_active:
|
| 239 |
raise credentials_exception
|
| 240 |
return user
|
| 241 |
|
| 242 |
|
| 243 |
-
def require_application_id(request: Request):
|
| 244 |
-
app_id = request.headers.get("X-Application-Id")
|
| 245 |
-
expected = _settings.application_id
|
| 246 |
-
if not expected:
|
| 247 |
-
return True
|
| 248 |
-
if not app_id:
|
| 249 |
-
raise HTTPException(
|
| 250 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 251 |
-
detail="Missing X-Application-Id header",
|
| 252 |
-
)
|
| 253 |
-
if app_id != expected:
|
| 254 |
-
raise HTTPException(
|
| 255 |
-
status_code=status.HTTP_403_FORBIDDEN,
|
| 256 |
-
detail="Invalid application_id",
|
| 257 |
-
)
|
| 258 |
-
return True
|
| 259 |
-
|
| 260 |
-
|
| 261 |
def require_permissions(*required_perms: str):
|
| 262 |
async def permission_checker(
|
| 263 |
user: Annotated[User, Depends(get_current_user)],
|
| 264 |
) -> User:
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
if
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
"JOIN role_permissions rp ON rp.permission_id = p.id "
|
| 271 |
-
"JOIN user_roles ur ON ur.role_id = rp.role_id "
|
| 272 |
-
"WHERE ur.user_id = %s",
|
| 273 |
-
(user.id,),
|
| 274 |
-
)
|
| 275 |
-
user_perms = {r["code"] for r in rows}
|
| 276 |
missing = [p for p in required_perms if p not in user_perms]
|
| 277 |
if missing:
|
| 278 |
raise HTTPException(
|
|
@@ -282,53 +144,3 @@ def require_permissions(*required_perms: str):
|
|
| 282 |
return user
|
| 283 |
|
| 284 |
return permission_checker
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
def _row_to_user(row: dict) -> User:
|
| 288 |
-
return User(
|
| 289 |
-
id=row["id"],
|
| 290 |
-
email=row["email"],
|
| 291 |
-
username=row.get("username"),
|
| 292 |
-
full_name=row.get("full_name"),
|
| 293 |
-
password_hash=row["password_hash"],
|
| 294 |
-
is_active=bool(row["is_active"]),
|
| 295 |
-
is_verified=bool(row["is_verified"]),
|
| 296 |
-
failed_login_attempts=row["failed_login_attempts"],
|
| 297 |
-
locked_until=_deserialize_dt(row.get("locked_until")),
|
| 298 |
-
last_login=_deserialize_dt(row.get("last_login")),
|
| 299 |
-
password_changed_at=_deserialize_dt(row.get("password_changed_at")),
|
| 300 |
-
created_at=_deserialize_dt(row["created_at"]),
|
| 301 |
-
updated_at=_deserialize_dt(row["updated_at"]),
|
| 302 |
-
deleted_at=_deserialize_dt(row.get("deleted_at")),
|
| 303 |
-
)
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
def _row_to_refresh_session(row: dict) -> RefreshSession:
|
| 307 |
-
return RefreshSession(
|
| 308 |
-
id=row["id"],
|
| 309 |
-
user_id=row["user_id"],
|
| 310 |
-
token_key=row["token_key"],
|
| 311 |
-
token_hash=row["token_hash"],
|
| 312 |
-
device_info=row.get("device_info"),
|
| 313 |
-
ip_address=row.get("ip_address"),
|
| 314 |
-
expires_at=_deserialize_dt(row["expires_at"]),
|
| 315 |
-
revoked_at=_deserialize_dt(row.get("revoked_at")),
|
| 316 |
-
created_at=_deserialize_dt(row["created_at"]),
|
| 317 |
-
updated_at=_deserialize_dt(row["updated_at"]),
|
| 318 |
-
)
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
def _serialize_dt(dt: Optional[datetime]) -> Optional[str]:
|
| 322 |
-
if dt is None:
|
| 323 |
-
return None
|
| 324 |
-
return dt.strftime("%Y-%m-%d %H:%M:%S.%f")
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
def _deserialize_dt(val) -> Optional[datetime]:
|
| 328 |
-
if val is None:
|
| 329 |
-
return None
|
| 330 |
-
if isinstance(val, datetime):
|
| 331 |
-
return val
|
| 332 |
-
if isinstance(val, str):
|
| 333 |
-
return datetime.fromisoformat(val)
|
| 334 |
-
return val
|
|
|
|
| 2 |
|
| 3 |
import logging
|
| 4 |
from datetime import datetime
|
| 5 |
+
from typing import Annotated, List, Optional
|
| 6 |
|
| 7 |
import jwt
|
| 8 |
from argon2 import PasswordHasher
|
|
|
|
| 11 |
from app.config import get_settings
|
| 12 |
from app.core.auth.models import RefreshSession, User, _uuid
|
| 13 |
from app.core.auth.models import _utcnow as _now
|
| 14 |
+
from app.services.supabase import AuthRepository, SupabaseClient, get_supabase_client
|
| 15 |
+
from app.services.supabase.repositories import _row_to_user
|
| 16 |
+
from app.services.supabase.client import create_supabase_client
|
| 17 |
|
| 18 |
logger = logging.getLogger("auth")
|
| 19 |
|
| 20 |
_settings = get_settings()
|
| 21 |
ph = PasswordHasher()
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
async def init_auth_db():
|
| 25 |
+
client = await create_supabase_client()
|
| 26 |
+
if client is None:
|
| 27 |
+
logger.error("Cannot initialize auth DB: Supabase not configured")
|
| 28 |
return
|
| 29 |
+
logger.info("Supabase auth client initialized")
|
|
|
|
| 30 |
|
| 31 |
+
repo = AuthRepository(client)
|
| 32 |
|
| 33 |
+
sa_role = await repo.get_role_by_name("SuperAdmin")
|
| 34 |
+
if not sa_role:
|
| 35 |
+
perm_ids: List[str] = []
|
|
|
|
|
|
|
|
|
|
| 36 |
for code, desc in [
|
| 37 |
("users:read", "Read users"),
|
| 38 |
("users:write", "Modify users"),
|
|
|
|
| 40 |
("admin:access", "Access admin panel"),
|
| 41 |
]:
|
| 42 |
pid = _uuid()
|
| 43 |
+
await client.insert("permissions", {
|
| 44 |
+
"id": pid,
|
| 45 |
+
"code": code,
|
| 46 |
+
"description": desc,
|
| 47 |
+
})
|
| 48 |
+
perm_ids.append(pid)
|
| 49 |
|
| 50 |
+
sa_role_id = _uuid()
|
| 51 |
+
admin_role_id = _uuid()
|
| 52 |
+
user_role_id = _uuid()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
+
await client.insert("roles", {"id": sa_role_id, "name": "SuperAdmin", "description": "Full system access"})
|
| 55 |
+
await client.insert("roles", {"id": admin_role_id, "name": "Admin", "description": "Administrative access"})
|
| 56 |
+
await client.insert("roles", {"id": user_role_id, "name": "User", "description": "Standard user access"})
|
| 57 |
+
|
| 58 |
+
for pid in perm_ids:
|
| 59 |
+
await client.insert("role_permissions", {"role_id": sa_role_id, "permission_id": pid})
|
| 60 |
+
for pid in perm_ids[:-1]:
|
| 61 |
+
await client.insert("role_permissions", {"role_id": admin_role_id, "permission_id": pid})
|
| 62 |
+
await client.insert("role_permissions", {"role_id": user_role_id, "permission_id": perm_ids[0]})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
admin_email = "admin@example.com"
|
| 65 |
+
admin_user = await repo.find_user_by_email(admin_email)
|
| 66 |
+
if not admin_user:
|
| 67 |
admin_password = _settings.admin_password or "Admin123!"
|
| 68 |
admin_id = _uuid()
|
| 69 |
now = _now()
|
| 70 |
+
await client.insert("users", {
|
| 71 |
+
"id": admin_id,
|
| 72 |
+
"email": admin_email,
|
| 73 |
+
"full_name": "System Administrator",
|
| 74 |
+
"password_hash": ph.hash(admin_password),
|
| 75 |
+
"is_verified": True,
|
| 76 |
+
"created_at": now.isoformat(),
|
| 77 |
+
"updated_at": now.isoformat(),
|
| 78 |
+
})
|
| 79 |
+
sa_role = await repo.get_role_by_name("SuperAdmin")
|
| 80 |
+
if sa_role:
|
| 81 |
+
await client.insert("user_roles", {"user_id": admin_id, "role_id": sa_role["id"]})
|
| 82 |
|
| 83 |
from app.services.auth_service import AuthService
|
| 84 |
+
await AuthService.cleanup_expired_sessions()
|
| 85 |
|
| 86 |
|
| 87 |
+
async def get_db() -> SupabaseClient:
|
| 88 |
+
client = get_supabase_client()
|
| 89 |
+
if client is None:
|
| 90 |
raise HTTPException(
|
| 91 |
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 92 |
+
detail="Database not initialized. Configure SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY.",
|
| 93 |
)
|
| 94 |
+
return client
|
| 95 |
|
| 96 |
|
| 97 |
async def get_current_user(
|
| 98 |
request: Request,
|
| 99 |
+
db: Annotated[SupabaseClient, Depends(get_db)],
|
| 100 |
) -> User:
|
| 101 |
credentials_exception = HTTPException(
|
| 102 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
|
| 119 |
except jwt.PyJWTError:
|
| 120 |
raise credentials_exception
|
| 121 |
|
| 122 |
+
repo = AuthRepository(db)
|
| 123 |
+
user = await repo.find_user_by_id(user_id)
|
| 124 |
+
if user is None or not user.is_active:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
raise credentials_exception
|
| 126 |
return user
|
| 127 |
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
def require_permissions(*required_perms: str):
|
| 130 |
async def permission_checker(
|
| 131 |
user: Annotated[User, Depends(get_current_user)],
|
| 132 |
) -> User:
|
| 133 |
+
client = get_supabase_client()
|
| 134 |
+
user_perms: set[str] = set()
|
| 135 |
+
if client:
|
| 136 |
+
repo = AuthRepository(client)
|
| 137 |
+
user_perms = set(await repo.get_user_permission_codes(user.id))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
missing = [p for p in required_perms if p not in user_perms]
|
| 139 |
if missing:
|
| 140 |
raise HTTPException(
|
|
|
|
| 144 |
return user
|
| 145 |
|
| 146 |
return permission_checker
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/core/auth/schemas.py
CHANGED
|
@@ -123,29 +123,23 @@ class ApiResponse(BaseModel):
|
|
| 123 |
|
| 124 |
class DataResponse(ApiResponse):
|
| 125 |
data: dict
|
| 126 |
-
application_id: Optional[str] = None
|
| 127 |
|
| 128 |
|
| 129 |
class TokenResponse(ApiResponse):
|
| 130 |
data: TokenData
|
| 131 |
-
application_id: Optional[str] = None
|
| 132 |
|
| 133 |
|
| 134 |
class ProfileResponse(ApiResponse):
|
| 135 |
data: UserProfile
|
| 136 |
-
application_id: Optional[str] = None
|
| 137 |
|
| 138 |
|
| 139 |
class MessageDataResponse(ApiResponse):
|
| 140 |
data: MessageResponse
|
| 141 |
-
application_id: Optional[str] = None
|
| 142 |
|
| 143 |
|
| 144 |
class SchemaResponse(ApiResponse):
|
| 145 |
data: UserSchemaResponse
|
| 146 |
-
application_id: Optional[str] = None
|
| 147 |
|
| 148 |
|
| 149 |
class SessionListResponse(ApiResponse):
|
| 150 |
data: list[SessionOut]
|
| 151 |
-
application_id: Optional[str] = None
|
|
|
|
| 123 |
|
| 124 |
class DataResponse(ApiResponse):
|
| 125 |
data: dict
|
|
|
|
| 126 |
|
| 127 |
|
| 128 |
class TokenResponse(ApiResponse):
|
| 129 |
data: TokenData
|
|
|
|
| 130 |
|
| 131 |
|
| 132 |
class ProfileResponse(ApiResponse):
|
| 133 |
data: UserProfile
|
|
|
|
| 134 |
|
| 135 |
|
| 136 |
class MessageDataResponse(ApiResponse):
|
| 137 |
data: MessageResponse
|
|
|
|
| 138 |
|
| 139 |
|
| 140 |
class SchemaResponse(ApiResponse):
|
| 141 |
data: UserSchemaResponse
|
|
|
|
| 142 |
|
| 143 |
|
| 144 |
class SessionListResponse(ApiResponse):
|
| 145 |
data: list[SessionOut]
|
|
|
app/core/banner.py
CHANGED
|
@@ -13,10 +13,10 @@ def get_banner() -> str:
|
|
| 13 |
f" {settings.app_name} v{settings.app_version}",
|
| 14 |
f" Environment: {settings.environment or 'production'}",
|
| 15 |
]
|
| 16 |
-
if settings.
|
| 17 |
-
lines.append(
|
| 18 |
else:
|
| 19 |
-
lines.append("
|
| 20 |
if settings.redis_url:
|
| 21 |
lines.append(" Redis: connected")
|
| 22 |
else:
|
|
|
|
| 13 |
f" {settings.app_name} v{settings.app_version}",
|
| 14 |
f" Environment: {settings.environment or 'production'}",
|
| 15 |
]
|
| 16 |
+
if settings.supabase_url and settings.supabase_service_role_key:
|
| 17 |
+
lines.append(" Supabase: connected")
|
| 18 |
else:
|
| 19 |
+
lines.append(" Supabase: not configured")
|
| 20 |
if settings.redis_url:
|
| 21 |
lines.append(" Redis: connected")
|
| 22 |
else:
|
app/core/tidb_manager.py
DELETED
|
@@ -1,341 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import asyncio
|
| 4 |
-
import logging
|
| 5 |
-
import ssl
|
| 6 |
-
from typing import Any, Dict, List, Optional, Set
|
| 7 |
-
from urllib.parse import urlparse
|
| 8 |
-
|
| 9 |
-
import aiomysql
|
| 10 |
-
|
| 11 |
-
logger = logging.getLogger(__name__)
|
| 12 |
-
|
| 13 |
-
# MySQL/TiDB error codes that indicate the instance cannot accept more writes:
|
| 14 |
-
# 1114 (ER_RECORD_FILE_FULL) – "The table '...' is full"
|
| 15 |
-
# 1021 (ER_DISK_FULL) – "Disk full (...)"
|
| 16 |
-
# 126 (ER_INDEX_FILE_FULL) – "Index file is full"
|
| 17 |
-
# 1877 (ER_TABLE_FULL) – "Table full"
|
| 18 |
-
_STORAGE_FULL_CODES: Set[int] = {1114, 1021, 126, 1877}
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
class TiDBWriteError(RuntimeError):
|
| 22 |
-
"""All TiDB instances are full or unavailable — writes cannot be accepted."""
|
| 23 |
-
def __init__(self):
|
| 24 |
-
super().__init__("No TiDB instances available for write. All instances are full or unreachable.")
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
def _is_storage_full_error(exc: Exception) -> bool:
|
| 28 |
-
if isinstance(exc, aiomysql.MySQLError):
|
| 29 |
-
code = exc.args[0] if exc.args else None
|
| 30 |
-
if isinstance(code, int) and code in _STORAGE_FULL_CODES:
|
| 31 |
-
return True
|
| 32 |
-
msg = str(exc).lower()
|
| 33 |
-
for keyword in ("table is full", "disk full", "storage full",
|
| 34 |
-
"quota exceeded", "out of storage"):
|
| 35 |
-
if keyword in msg:
|
| 36 |
-
return True
|
| 37 |
-
return False
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
class TiDBInstance:
|
| 41 |
-
def __init__(self, host: str, port: int, user: str, password: str, database: str,
|
| 42 |
-
ssl: Optional[ssl.SSLContext] = None):
|
| 43 |
-
self.host = host
|
| 44 |
-
self.port = port
|
| 45 |
-
self.user = user
|
| 46 |
-
self.password = password
|
| 47 |
-
self.database = database
|
| 48 |
-
self.ssl = ssl
|
| 49 |
-
self.pool: Optional[aiomysql.Pool] = None
|
| 50 |
-
self.is_full = False
|
| 51 |
-
self.is_available = True
|
| 52 |
-
|
| 53 |
-
@classmethod
|
| 54 |
-
def from_url(cls, url: str,
|
| 55 |
-
ssl: Optional[ssl.SSLContext] = None) -> TiDBInstance:
|
| 56 |
-
parsed = urlparse(url)
|
| 57 |
-
host = parsed.hostname or "localhost"
|
| 58 |
-
port = parsed.port or 4000
|
| 59 |
-
user = parsed.username or "root"
|
| 60 |
-
password = parsed.password or ""
|
| 61 |
-
database = parsed.path.lstrip("/") or "test"
|
| 62 |
-
return cls(host=host, port=port, user=user, password=password,
|
| 63 |
-
database=database, ssl=ssl)
|
| 64 |
-
|
| 65 |
-
async def create_pool(self, minsize: int = 1, maxsize: int = 10):
|
| 66 |
-
self.pool = await aiomysql.create_pool(
|
| 67 |
-
host=self.host,
|
| 68 |
-
port=self.port,
|
| 69 |
-
user=self.user,
|
| 70 |
-
password=self.password,
|
| 71 |
-
db=self.database,
|
| 72 |
-
minsize=minsize,
|
| 73 |
-
maxsize=maxsize,
|
| 74 |
-
autocommit=False,
|
| 75 |
-
cursorclass=aiomysql.DictCursor,
|
| 76 |
-
ssl=self.ssl,
|
| 77 |
-
)
|
| 78 |
-
self.is_available = True
|
| 79 |
-
return self.pool
|
| 80 |
-
|
| 81 |
-
async def close_pool(self):
|
| 82 |
-
if self.pool:
|
| 83 |
-
await self.pool.close()
|
| 84 |
-
await self.pool.wait_closed()
|
| 85 |
-
self.pool = None
|
| 86 |
-
|
| 87 |
-
def __repr__(self) -> str:
|
| 88 |
-
user_prefix = self.user.split(".")[0] if "." in self.user else self.user
|
| 89 |
-
return f"TiDBInstance({user_prefix}@...)"
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
class TiDBManager:
|
| 93 |
-
def __init__(self, instance_urls: List[str],
|
| 94 |
-
ssl: Optional[ssl.SSLContext] = None):
|
| 95 |
-
self.instances = [TiDBInstance.from_url(url, ssl=ssl) for url in instance_urls]
|
| 96 |
-
self._current_instance_index = 0
|
| 97 |
-
|
| 98 |
-
@property
|
| 99 |
-
def current_instance(self) -> TiDBInstance:
|
| 100 |
-
return self.instances[self._current_instance_index]
|
| 101 |
-
|
| 102 |
-
@property
|
| 103 |
-
def current_index(self) -> int:
|
| 104 |
-
return self._current_instance_index
|
| 105 |
-
|
| 106 |
-
async def initialize_pools(self, minsize: int = 1, maxsize: int = 10):
|
| 107 |
-
for i, inst in enumerate(self.instances, 1):
|
| 108 |
-
try:
|
| 109 |
-
await inst.create_pool(minsize=minsize, maxsize=maxsize)
|
| 110 |
-
logger.info("Connected to TiDB instance %d/%d", i, len(self.instances))
|
| 111 |
-
except Exception as exc:
|
| 112 |
-
logger.error("Failed to connect to TiDB instance %d/%d: %s", i, len(self.instances), exc)
|
| 113 |
-
inst.is_available = False
|
| 114 |
-
|
| 115 |
-
async def close_all_pools(self):
|
| 116 |
-
for inst in self.instances:
|
| 117 |
-
await inst.close_pool()
|
| 118 |
-
|
| 119 |
-
async def ensure_tables(self, table_ddls: Dict[str, str]):
|
| 120 |
-
for i, inst in enumerate(self.instances, 1):
|
| 121 |
-
if not inst.is_available or not inst.pool:
|
| 122 |
-
continue
|
| 123 |
-
async with inst.pool.acquire() as conn:
|
| 124 |
-
async with conn.cursor() as cur:
|
| 125 |
-
for table_name, ddl in table_ddls.items():
|
| 126 |
-
try:
|
| 127 |
-
await cur.execute(
|
| 128 |
-
"SELECT 1 FROM information_schema.tables "
|
| 129 |
-
"WHERE table_schema = %s AND table_name = %s",
|
| 130 |
-
(inst.database, table_name),
|
| 131 |
-
)
|
| 132 |
-
exists = await cur.fetchone()
|
| 133 |
-
if not exists:
|
| 134 |
-
for statement in ddl.split(";"):
|
| 135 |
-
stmt = statement.strip()
|
| 136 |
-
if stmt:
|
| 137 |
-
await cur.execute(stmt)
|
| 138 |
-
logger.info("Created table '%s' (instance %d/%d)", table_name, i, len(self.instances))
|
| 139 |
-
except Exception as exc:
|
| 140 |
-
logger.error(
|
| 141 |
-
"Failed to create table '%s' (instance %d/%d): %s",
|
| 142 |
-
table_name, i, len(self.instances), exc,
|
| 143 |
-
)
|
| 144 |
-
await conn.commit()
|
| 145 |
-
|
| 146 |
-
async def has_table(self, table_name: str) -> bool:
|
| 147 |
-
inst = self.current_instance
|
| 148 |
-
if not inst.pool:
|
| 149 |
-
return False
|
| 150 |
-
async with inst.pool.acquire() as conn:
|
| 151 |
-
async with conn.cursor() as cur:
|
| 152 |
-
await cur.execute(
|
| 153 |
-
"SELECT 1 FROM information_schema.tables "
|
| 154 |
-
"WHERE table_schema = %s AND table_name = %s",
|
| 155 |
-
(inst.database, table_name),
|
| 156 |
-
)
|
| 157 |
-
return await cur.fetchone() is not None
|
| 158 |
-
|
| 159 |
-
async def check_and_rotate(self) -> bool:
|
| 160 |
-
if self.current_instance.is_available and not self.current_instance.is_full:
|
| 161 |
-
return True
|
| 162 |
-
|
| 163 |
-
for i in range(1, len(self.instances)):
|
| 164 |
-
idx = (self._current_instance_index + i) % len(self.instances)
|
| 165 |
-
inst = self.instances[idx]
|
| 166 |
-
if inst.is_available and not inst.is_full:
|
| 167 |
-
logger.info(
|
| 168 |
-
"Rotating TiDB write target: instance %d -> %d",
|
| 169 |
-
self._current_instance_index + 1, idx + 1,
|
| 170 |
-
)
|
| 171 |
-
self._current_instance_index = idx
|
| 172 |
-
return True
|
| 173 |
-
|
| 174 |
-
logger.error("All TiDB instances are full or unavailable")
|
| 175 |
-
return False
|
| 176 |
-
|
| 177 |
-
async def fetchone(self, query: str, params=None) -> Optional[Dict[str, Any]]:
|
| 178 |
-
inst = self.current_instance
|
| 179 |
-
if not inst.pool:
|
| 180 |
-
return None
|
| 181 |
-
async with inst.pool.acquire() as conn:
|
| 182 |
-
async with conn.cursor() as cur:
|
| 183 |
-
await cur.execute(query, params or ())
|
| 184 |
-
return await cur.fetchone()
|
| 185 |
-
|
| 186 |
-
async def fetchone_all(self, query: str, params=None) -> Optional[Dict[str, Any]]:
|
| 187 |
-
async def _query(inst_: TiDBInstance):
|
| 188 |
-
try:
|
| 189 |
-
async with inst_.pool.acquire() as conn:
|
| 190 |
-
async with conn.cursor() as cur:
|
| 191 |
-
await cur.execute(query, params or ())
|
| 192 |
-
return await cur.fetchone()
|
| 193 |
-
except asyncio.CancelledError:
|
| 194 |
-
raise
|
| 195 |
-
except Exception as exc:
|
| 196 |
-
logger.error("Error reading from %s: %s", inst_, exc)
|
| 197 |
-
return None
|
| 198 |
-
|
| 199 |
-
tasks = [
|
| 200 |
-
asyncio.create_task(_query(inst))
|
| 201 |
-
for inst in self.instances
|
| 202 |
-
if inst.pool and inst.is_available
|
| 203 |
-
]
|
| 204 |
-
if not tasks:
|
| 205 |
-
return None
|
| 206 |
-
|
| 207 |
-
for coro in asyncio.as_completed(tasks):
|
| 208 |
-
result = await coro
|
| 209 |
-
if result is not None:
|
| 210 |
-
for t in tasks:
|
| 211 |
-
if not t.done():
|
| 212 |
-
t.cancel()
|
| 213 |
-
return result
|
| 214 |
-
return None
|
| 215 |
-
|
| 216 |
-
async def fetchall(self, query: str, params=None) -> List[Dict[str, Any]]:
|
| 217 |
-
inst = self.current_instance
|
| 218 |
-
if not inst.pool:
|
| 219 |
-
return []
|
| 220 |
-
async with inst.pool.acquire() as conn:
|
| 221 |
-
async with conn.cursor() as cur:
|
| 222 |
-
await cur.execute(query, params or ())
|
| 223 |
-
return await cur.fetchall()
|
| 224 |
-
|
| 225 |
-
async def fetchall_all(self, query: str, params=None) -> List[Dict[str, Any]]:
|
| 226 |
-
async def _query(inst_: TiDBInstance):
|
| 227 |
-
try:
|
| 228 |
-
async with inst_.pool.acquire() as conn:
|
| 229 |
-
async with conn.cursor() as cur:
|
| 230 |
-
await cur.execute(query, params or ())
|
| 231 |
-
return await cur.fetchall()
|
| 232 |
-
except asyncio.CancelledError:
|
| 233 |
-
raise
|
| 234 |
-
except Exception as exc:
|
| 235 |
-
logger.error("Error reading from %s: %s", inst_, exc)
|
| 236 |
-
return []
|
| 237 |
-
|
| 238 |
-
tasks = [
|
| 239 |
-
asyncio.create_task(_query(inst))
|
| 240 |
-
for inst in self.instances
|
| 241 |
-
if inst.pool and inst.is_available
|
| 242 |
-
]
|
| 243 |
-
if not tasks:
|
| 244 |
-
return []
|
| 245 |
-
|
| 246 |
-
all_rows = await asyncio.gather(*tasks)
|
| 247 |
-
seen = set()
|
| 248 |
-
merged = []
|
| 249 |
-
for rows in all_rows:
|
| 250 |
-
for row in rows:
|
| 251 |
-
key = tuple(row.items())
|
| 252 |
-
if key not in seen:
|
| 253 |
-
seen.add(key)
|
| 254 |
-
merged.append(row)
|
| 255 |
-
return merged
|
| 256 |
-
|
| 257 |
-
async def _try_write(self, query: str, params) -> int:
|
| 258 |
-
inst = self.current_instance
|
| 259 |
-
if not inst.pool:
|
| 260 |
-
raise RuntimeError(f"TiDB pool not initialized for {inst}")
|
| 261 |
-
async with inst.pool.acquire() as conn:
|
| 262 |
-
async with conn.cursor(aiomysql.DictCursor) as cur:
|
| 263 |
-
await cur.execute(query, params or ())
|
| 264 |
-
await conn.commit()
|
| 265 |
-
return cur.lastrowid if cur.lastrowid is not None else 0
|
| 266 |
-
|
| 267 |
-
async def execute(self, query: str, params=None) -> int:
|
| 268 |
-
ok = await self.check_and_rotate()
|
| 269 |
-
if not ok:
|
| 270 |
-
raise TiDBWriteError()
|
| 271 |
-
try:
|
| 272 |
-
return await self._try_write(query, params)
|
| 273 |
-
except Exception as exc:
|
| 274 |
-
if _is_storage_full_error(exc):
|
| 275 |
-
logger.warning("TiDB write target is full, rotating...")
|
| 276 |
-
self.current_instance.is_full = True
|
| 277 |
-
ok = await self.check_and_rotate()
|
| 278 |
-
if not ok:
|
| 279 |
-
raise TiDBWriteError() from exc
|
| 280 |
-
try:
|
| 281 |
-
return await self._try_write(query, params)
|
| 282 |
-
except Exception:
|
| 283 |
-
raise TiDBWriteError() from exc
|
| 284 |
-
raise
|
| 285 |
-
|
| 286 |
-
async def execute_on_all(self, query: str, params=None):
|
| 287 |
-
for i, inst in enumerate(self.instances, 1):
|
| 288 |
-
if not inst.is_available or not inst.pool:
|
| 289 |
-
continue
|
| 290 |
-
try:
|
| 291 |
-
async with inst.pool.acquire() as conn:
|
| 292 |
-
async with conn.cursor(aiomysql.DictCursor) as cur:
|
| 293 |
-
await cur.execute(query, params or ())
|
| 294 |
-
await conn.commit()
|
| 295 |
-
except Exception as exc:
|
| 296 |
-
logger.error("Error executing on instance %d/%d: %s", i, len(self.instances), exc)
|
| 297 |
-
|
| 298 |
-
async def insert_and_get_id(self, query: str, params=None) -> int:
|
| 299 |
-
ok = await self.check_and_rotate()
|
| 300 |
-
if not ok:
|
| 301 |
-
raise TiDBWriteError()
|
| 302 |
-
try:
|
| 303 |
-
return await self._try_write(query, params)
|
| 304 |
-
except Exception as exc:
|
| 305 |
-
if _is_storage_full_error(exc):
|
| 306 |
-
logger.warning("TiDB write target is full, rotating...")
|
| 307 |
-
self.current_instance.is_full = True
|
| 308 |
-
ok = await self.check_and_rotate()
|
| 309 |
-
if not ok:
|
| 310 |
-
raise TiDBWriteError() from exc
|
| 311 |
-
try:
|
| 312 |
-
return await self._try_write(query, params)
|
| 313 |
-
except Exception:
|
| 314 |
-
raise TiDBWriteError() from exc
|
| 315 |
-
raise
|
| 316 |
-
|
| 317 |
-
def get_instances_summary(self) -> List[Dict[str, Any]]:
|
| 318 |
-
return [
|
| 319 |
-
{
|
| 320 |
-
"host": inst.host,
|
| 321 |
-
"port": inst.port,
|
| 322 |
-
"database": inst.database,
|
| 323 |
-
"is_available": inst.is_available,
|
| 324 |
-
"is_full": inst.is_full,
|
| 325 |
-
"is_current": i == self._current_instance_index,
|
| 326 |
-
}
|
| 327 |
-
for i, inst in enumerate(self.instances)
|
| 328 |
-
]
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
_tidb_manager: Optional[TiDBManager] = None
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
def get_tidb_manager() -> Optional[TiDBManager]:
|
| 335 |
-
global _tidb_manager
|
| 336 |
-
return _tidb_manager
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
def set_tidb_manager(mgr: TiDBManager):
|
| 340 |
-
global _tidb_manager
|
| 341 |
-
_tidb_manager = mgr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/core/vector_store/__init__.py
CHANGED
|
@@ -1,17 +1,8 @@
|
|
| 1 |
-
from
|
| 2 |
-
|
| 3 |
-
from app.core.vector_store.deps import (
|
| 4 |
-
get_vs_db,
|
| 5 |
-
get_vs_manager,
|
| 6 |
-
init_vector_store_db,
|
| 7 |
-
set_vs_manager,
|
| 8 |
-
)
|
| 9 |
from app.core.vector_store.models import VectorStoreIndex
|
| 10 |
|
| 11 |
__all__ = [
|
| 12 |
"VectorStoreIndex",
|
| 13 |
"init_vector_store_db",
|
| 14 |
"get_vs_db",
|
| 15 |
-
"set_vs_manager",
|
| 16 |
-
"get_vs_manager",
|
| 17 |
]
|
|
|
|
| 1 |
+
from app.core.vector_store.deps import get_vs_db, init_vector_store_db
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from app.core.vector_store.models import VectorStoreIndex
|
| 3 |
|
| 4 |
__all__ = [
|
| 5 |
"VectorStoreIndex",
|
| 6 |
"init_vector_store_db",
|
| 7 |
"get_vs_db",
|
|
|
|
|
|
|
| 8 |
]
|
app/core/vector_store/deps.py
CHANGED
|
@@ -4,49 +4,21 @@ import logging
|
|
| 4 |
from typing import Optional
|
| 5 |
|
| 6 |
from app.config import get_settings
|
| 7 |
-
from app.
|
| 8 |
|
| 9 |
logger = logging.getLogger(__name__)
|
| 10 |
|
| 11 |
-
_settings = get_settings()
|
| 12 |
-
|
| 13 |
-
VS_TABLES: dict[str, str] = {
|
| 14 |
-
"vector_store_index": """
|
| 15 |
-
CREATE TABLE IF NOT EXISTS vector_store_index (
|
| 16 |
-
store_id VARCHAR(36) PRIMARY KEY,
|
| 17 |
-
name VARCHAR(255) NOT NULL,
|
| 18 |
-
path VARCHAR(1024) NOT NULL,
|
| 19 |
-
description VARCHAR(1024) DEFAULT '',
|
| 20 |
-
metadata_json TEXT,
|
| 21 |
-
created_at VARCHAR(64) NOT NULL
|
| 22 |
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 23 |
-
""",
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
|
| 27 |
async def init_vector_store_db():
|
| 28 |
-
|
| 29 |
-
if
|
| 30 |
-
logger.error("Cannot init vector store DB:
|
| 31 |
return
|
| 32 |
-
|
| 33 |
-
logger.info("Vector store tables ensured on all TiDB instances")
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
async def get_vs_db() -> TiDBManager:
|
| 37 |
-
mgr = get_tidb_manager()
|
| 38 |
-
if mgr is None:
|
| 39 |
-
raise RuntimeError("TiDB manager not initialized")
|
| 40 |
-
return mgr
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
_tidb_vs_manager: Optional[TiDBManager] = None
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
def set_vs_manager(mgr: TiDBManager):
|
| 47 |
-
global _tidb_vs_manager
|
| 48 |
-
_tidb_vs_manager = mgr
|
| 49 |
|
| 50 |
|
| 51 |
-
def
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
from typing import Optional
|
| 5 |
|
| 6 |
from app.config import get_settings
|
| 7 |
+
from app.services.supabase import SupabaseClient, get_supabase_client
|
| 8 |
|
| 9 |
logger = logging.getLogger(__name__)
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
async def init_vector_store_db():
|
| 13 |
+
client = get_supabase_client()
|
| 14 |
+
if client is None:
|
| 15 |
+
logger.error("Cannot init vector store DB: Supabase not initialized")
|
| 16 |
return
|
| 17 |
+
logger.info("Vector store DB initialized via Supabase")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
+
async def get_vs_db() -> SupabaseClient:
|
| 21 |
+
client = get_supabase_client()
|
| 22 |
+
if client is None:
|
| 23 |
+
raise RuntimeError("Supabase client not initialized")
|
| 24 |
+
return client
|
app/models/schemas.py
CHANGED
|
@@ -568,7 +568,6 @@ class VectorStoreCreate(BaseModel):
|
|
| 568 |
class VectorStoreResponse(BaseModel):
|
| 569 |
success: bool
|
| 570 |
vector_store_id: str
|
| 571 |
-
app_id: str
|
| 572 |
name: str
|
| 573 |
description: Optional[str] = None
|
| 574 |
embedding_dimension: int
|
|
|
|
| 568 |
class VectorStoreResponse(BaseModel):
|
| 569 |
success: bool
|
| 570 |
vector_store_id: str
|
|
|
|
| 571 |
name: str
|
| 572 |
description: Optional[str] = None
|
| 573 |
embedding_dimension: int
|
app/services/auth_service.py
CHANGED
|
@@ -11,7 +11,6 @@ from argon2.exceptions import VerifyMismatchError
|
|
| 11 |
from fastapi import HTTPException, Request
|
| 12 |
|
| 13 |
from app.config import get_settings
|
| 14 |
-
from app.core.auth.deps import _row_to_refresh_session, _row_to_user, _serialize_dt
|
| 15 |
from app.core.auth.models import RefreshSession, User, _uuid
|
| 16 |
from app.core.auth.models import _utcnow as _now
|
| 17 |
from app.core.auth.schemas import (
|
|
@@ -24,7 +23,7 @@ from app.core.auth.schemas import (
|
|
| 24 |
UpdateProfileSchema,
|
| 25 |
UserProfile,
|
| 26 |
)
|
| 27 |
-
from app.
|
| 28 |
|
| 29 |
logger = logging.getLogger("auth_service")
|
| 30 |
_settings = get_settings()
|
|
@@ -38,50 +37,44 @@ def _token_key(raw: str) -> str:
|
|
| 38 |
class AuthService:
|
| 39 |
|
| 40 |
@staticmethod
|
| 41 |
-
async def register(db:
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
)
|
| 46 |
if existing:
|
| 47 |
raise HTTPException(status_code=409, detail="Email already registered")
|
| 48 |
|
| 49 |
if schema.username:
|
| 50 |
-
existing = await
|
| 51 |
-
"SELECT id FROM users WHERE username = %s AND deleted_at IS NULL",
|
| 52 |
-
(schema.username,),
|
| 53 |
-
)
|
| 54 |
if existing:
|
| 55 |
raise HTTPException(status_code=409, detail="Username already taken")
|
| 56 |
|
| 57 |
user_id = _uuid()
|
| 58 |
now = _now()
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
)
|
|
|
|
| 64 |
|
| 65 |
-
role_row = await
|
| 66 |
if role_row:
|
| 67 |
-
await
|
| 68 |
-
"INSERT INTO user_roles (user_id, role_id) VALUES (%s, %s)",
|
| 69 |
-
(user_id, role_row["id"]),
|
| 70 |
-
)
|
| 71 |
|
| 72 |
-
|
| 73 |
-
return _row_to_user(user_row)
|
| 74 |
|
| 75 |
@staticmethod
|
| 76 |
-
async def login(db:
|
| 77 |
-
|
| 78 |
-
"SELECT * FROM users WHERE email = %s AND deleted_at IS NULL",
|
| 79 |
-
(schema.email,),
|
| 80 |
-
)
|
| 81 |
-
if not row:
|
| 82 |
-
raise HTTPException(status_code=401, detail="Incorrect email or password")
|
| 83 |
|
| 84 |
-
user =
|
|
|
|
|
|
|
| 85 |
|
| 86 |
if user.locked_until and user.locked_until > _now():
|
| 87 |
raise HTTPException(
|
|
@@ -93,148 +86,118 @@ class AuthService:
|
|
| 93 |
user.failed_login_attempts += 1
|
| 94 |
if user.failed_login_attempts >= _settings.max_login_attempts:
|
| 95 |
user.locked_until = _now() + timedelta(minutes=_settings.lockout_minutes)
|
| 96 |
-
await
|
| 97 |
-
"
|
| 98 |
-
|
| 99 |
-
)
|
| 100 |
raise HTTPException(status_code=401, detail="Incorrect email or password")
|
| 101 |
|
| 102 |
now = _now()
|
| 103 |
-
await
|
| 104 |
-
"
|
| 105 |
-
"
|
| 106 |
-
|
| 107 |
-
)
|
| 108 |
|
| 109 |
access_token = AuthService._create_access_token(user.id)
|
| 110 |
raw_refresh, refresh_hash, token_key, expires_at = AuthService._create_refresh_token()
|
| 111 |
|
| 112 |
session_id = _uuid()
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
)
|
|
|
|
| 124 |
|
| 125 |
return TokenData(access_token=access_token, refresh_token=raw_refresh)
|
| 126 |
|
| 127 |
@staticmethod
|
| 128 |
-
async def refresh(db:
|
|
|
|
| 129 |
key = _token_key(raw_refresh_token)
|
| 130 |
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
if not row:
|
| 137 |
-
comp = await db.fetchone_all(
|
| 138 |
-
"SELECT * FROM refresh_sessions WHERE token_key = %s", (key,),
|
| 139 |
-
)
|
| 140 |
-
if comp and comp.get("revoked_at") is not None:
|
| 141 |
-
now = _serialize_dt(_now())
|
| 142 |
-
await db.execute_on_all(
|
| 143 |
-
"UPDATE refresh_sessions SET revoked_at = %s "
|
| 144 |
-
"WHERE user_id = %s AND revoked_at IS NULL",
|
| 145 |
-
(now, comp["user_id"]),
|
| 146 |
-
)
|
| 147 |
raise HTTPException(
|
| 148 |
status_code=401,
|
| 149 |
detail="Session compromised. All sessions revoked. Please login again.",
|
| 150 |
)
|
| 151 |
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
| 152 |
|
| 153 |
-
session = _row_to_refresh_session(row)
|
| 154 |
-
|
| 155 |
if not AuthService._verify_token(raw_refresh_token, session.token_hash):
|
| 156 |
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
| 157 |
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
(session.user_id,),
|
| 161 |
-
)
|
| 162 |
-
if not user_row:
|
| 163 |
raise HTTPException(status_code=401, detail="User not found or inactive")
|
| 164 |
-
user = _row_to_user(user_row)
|
| 165 |
|
| 166 |
-
now =
|
| 167 |
-
await
|
| 168 |
-
"UPDATE refresh_sessions SET revoked_at = %s WHERE id = %s",
|
| 169 |
-
(now, session.id),
|
| 170 |
-
)
|
| 171 |
|
| 172 |
new_access = AuthService._create_access_token(user.id)
|
| 173 |
new_raw_refresh, new_hash, new_key, new_expires = AuthService._create_refresh_token()
|
| 174 |
|
| 175 |
new_session_id = _uuid()
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
|
|
|
| 185 |
)
|
|
|
|
| 186 |
|
| 187 |
-
await
|
| 188 |
-
"UPDATE users SET last_login = %s WHERE id = %s",
|
| 189 |
-
(_serialize_dt(_now()), user.id),
|
| 190 |
-
)
|
| 191 |
|
| 192 |
return TokenData(access_token=new_access, refresh_token=new_raw_refresh)
|
| 193 |
|
| 194 |
@staticmethod
|
| 195 |
-
async def logout(db:
|
|
|
|
| 196 |
key = _token_key(raw_refresh_token)
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
"AND user_id = %s AND revoked_at IS NULL",
|
| 200 |
-
(key, user.id),
|
| 201 |
-
)
|
| 202 |
-
if not row:
|
| 203 |
raise HTTPException(status_code=404, detail="Session not found")
|
| 204 |
-
await
|
| 205 |
-
"UPDATE refresh_sessions SET revoked_at = %s WHERE id = %s",
|
| 206 |
-
(_serialize_dt(_now()), row["id"]),
|
| 207 |
-
)
|
| 208 |
|
| 209 |
@staticmethod
|
| 210 |
-
async def logout_all(db:
|
| 211 |
-
|
| 212 |
-
await
|
| 213 |
-
"UPDATE refresh_sessions SET revoked_at = %s "
|
| 214 |
-
"WHERE user_id = %s AND revoked_at IS NULL",
|
| 215 |
-
(now, user.id),
|
| 216 |
-
)
|
| 217 |
|
| 218 |
@staticmethod
|
| 219 |
-
async def change_password(db:
|
| 220 |
if not AuthService._verify_password(schema.current_password, user.password_hash):
|
| 221 |
raise HTTPException(status_code=400, detail="Incorrect current password")
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
|
|
|
| 227 |
|
| 228 |
@staticmethod
|
| 229 |
-
async def forgot_password(db:
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
)
|
| 234 |
-
if row:
|
| 235 |
reset_token = jwt.encode(
|
| 236 |
{
|
| 237 |
-
"sub":
|
| 238 |
"type": "reset_password",
|
| 239 |
"exp": _now() + timedelta(hours=1),
|
| 240 |
},
|
|
@@ -244,7 +207,7 @@ class AuthService:
|
|
| 244 |
logger.info("Password reset token for %s: %s", schema.email, reset_token)
|
| 245 |
|
| 246 |
@staticmethod
|
| 247 |
-
async def reset_password(db:
|
| 248 |
try:
|
| 249 |
payload = jwt.decode(
|
| 250 |
schema.token,
|
|
@@ -257,90 +220,63 @@ class AuthService:
|
|
| 257 |
except jwt.PyJWTError:
|
| 258 |
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
|
| 259 |
|
| 260 |
-
|
| 261 |
-
|
|
|
|
| 262 |
raise HTTPException(status_code=404, detail="User not found")
|
| 263 |
|
| 264 |
-
now =
|
| 265 |
-
await
|
| 266 |
-
"
|
| 267 |
-
|
| 268 |
-
)
|
| 269 |
-
await
|
| 270 |
-
"UPDATE refresh_sessions SET revoked_at = %s "
|
| 271 |
-
"WHERE user_id = %s AND revoked_at IS NULL",
|
| 272 |
-
(now, user_id),
|
| 273 |
-
)
|
| 274 |
|
| 275 |
@staticmethod
|
| 276 |
-
async def update_profile(db:
|
|
|
|
| 277 |
if schema.username is not None:
|
| 278 |
-
existing = await
|
| 279 |
-
|
| 280 |
-
(schema.username, user.id),
|
| 281 |
-
)
|
| 282 |
-
if existing:
|
| 283 |
raise HTTPException(status_code=409, detail="Username already taken")
|
| 284 |
-
await
|
| 285 |
if schema.full_name is not None:
|
| 286 |
-
await
|
| 287 |
-
|
| 288 |
-
(schema.full_name, user.id),
|
| 289 |
-
)
|
| 290 |
-
row = await db.fetchone("SELECT * FROM users WHERE id = %s", (user.id,))
|
| 291 |
-
return _row_to_user(row)
|
| 292 |
|
| 293 |
@staticmethod
|
| 294 |
-
async def soft_delete(db:
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
"WHERE user_id = %s AND revoked_at IS NULL",
|
| 303 |
-
(now, user.id),
|
| 304 |
-
)
|
| 305 |
|
| 306 |
@staticmethod
|
| 307 |
-
async def list_sessions(db:
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
"ORDER BY created_at DESC",
|
| 311 |
-
(user.id,),
|
| 312 |
-
)
|
| 313 |
-
return [_row_to_refresh_session(r) for r in rows]
|
| 314 |
|
| 315 |
@staticmethod
|
| 316 |
-
async def revoke_session(db:
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
if not row:
|
| 322 |
raise HTTPException(status_code=404, detail="Session not found")
|
| 323 |
-
await
|
| 324 |
-
"UPDATE refresh_sessions SET revoked_at = %s WHERE id = %s",
|
| 325 |
-
(_serialize_dt(_now()), session_id),
|
| 326 |
-
)
|
| 327 |
|
| 328 |
@staticmethod
|
| 329 |
-
async def cleanup_expired_sessions(
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
await db.execute(
|
| 338 |
-
"UPDATE refresh_sessions SET revoked_at = %s WHERE id = %s",
|
| 339 |
-
(_serialize_dt(_now()), row["id"]),
|
| 340 |
-
)
|
| 341 |
-
count += 1
|
| 342 |
-
if count:
|
| 343 |
-
logger.info("Cleaned up %s expired sessions", count)
|
| 344 |
|
| 345 |
@staticmethod
|
| 346 |
def _create_access_token(user_id: str) -> str:
|
|
@@ -379,17 +315,12 @@ class AuthService:
|
|
| 379 |
return False
|
| 380 |
|
| 381 |
@staticmethod
|
| 382 |
-
async def user_to_profile(db:
|
| 383 |
-
roles = []
|
| 384 |
if db:
|
| 385 |
try:
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
"JOIN user_roles ur ON ur.role_id = r.id "
|
| 389 |
-
"WHERE ur.user_id = %s",
|
| 390 |
-
(user.id,),
|
| 391 |
-
)
|
| 392 |
-
roles = [r["name"] for r in rows]
|
| 393 |
except Exception:
|
| 394 |
pass
|
| 395 |
return UserProfile(
|
|
|
|
| 11 |
from fastapi import HTTPException, Request
|
| 12 |
|
| 13 |
from app.config import get_settings
|
|
|
|
| 14 |
from app.core.auth.models import RefreshSession, User, _uuid
|
| 15 |
from app.core.auth.models import _utcnow as _now
|
| 16 |
from app.core.auth.schemas import (
|
|
|
|
| 23 |
UpdateProfileSchema,
|
| 24 |
UserProfile,
|
| 25 |
)
|
| 26 |
+
from app.services.supabase import AuthRepository, SupabaseClient
|
| 27 |
|
| 28 |
logger = logging.getLogger("auth_service")
|
| 29 |
_settings = get_settings()
|
|
|
|
| 37 |
class AuthService:
|
| 38 |
|
| 39 |
@staticmethod
|
| 40 |
+
async def register(db: SupabaseClient, schema: RegisterSchema) -> User:
|
| 41 |
+
repo = AuthRepository(db)
|
| 42 |
+
|
| 43 |
+
existing = await repo.find_user_by_email(schema.email)
|
|
|
|
| 44 |
if existing:
|
| 45 |
raise HTTPException(status_code=409, detail="Email already registered")
|
| 46 |
|
| 47 |
if schema.username:
|
| 48 |
+
existing = await repo.find_user_by_username(schema.username)
|
|
|
|
|
|
|
|
|
|
| 49 |
if existing:
|
| 50 |
raise HTTPException(status_code=409, detail="Username already taken")
|
| 51 |
|
| 52 |
user_id = _uuid()
|
| 53 |
now = _now()
|
| 54 |
+
user = User(
|
| 55 |
+
id=user_id,
|
| 56 |
+
email=schema.email,
|
| 57 |
+
username=schema.username,
|
| 58 |
+
full_name=schema.full_name,
|
| 59 |
+
password_hash=ph.hash(schema.password),
|
| 60 |
+
created_at=now,
|
| 61 |
+
updated_at=now,
|
| 62 |
)
|
| 63 |
+
await repo.create_user(user)
|
| 64 |
|
| 65 |
+
role_row = await repo.get_role_by_name("User")
|
| 66 |
if role_row:
|
| 67 |
+
await repo.assign_role(user_id, role_row["id"])
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
+
return await repo.find_user_by_id(user_id)
|
|
|
|
| 70 |
|
| 71 |
@staticmethod
|
| 72 |
+
async def login(db: SupabaseClient, request: Request, schema: LoginSchema) -> TokenData:
|
| 73 |
+
repo = AuthRepository(db)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
+
user = await repo.find_user_by_email(schema.email)
|
| 76 |
+
if not user:
|
| 77 |
+
raise HTTPException(status_code=401, detail="Incorrect email or password")
|
| 78 |
|
| 79 |
if user.locked_until and user.locked_until > _now():
|
| 80 |
raise HTTPException(
|
|
|
|
| 86 |
user.failed_login_attempts += 1
|
| 87 |
if user.failed_login_attempts >= _settings.max_login_attempts:
|
| 88 |
user.locked_until = _now() + timedelta(minutes=_settings.lockout_minutes)
|
| 89 |
+
await repo.update_user(user.id, {
|
| 90 |
+
"failed_login_attempts": user.failed_login_attempts,
|
| 91 |
+
"locked_until": user.locked_until,
|
| 92 |
+
})
|
| 93 |
raise HTTPException(status_code=401, detail="Incorrect email or password")
|
| 94 |
|
| 95 |
now = _now()
|
| 96 |
+
await repo.update_user(user.id, {
|
| 97 |
+
"failed_login_attempts": 0,
|
| 98 |
+
"locked_until": None,
|
| 99 |
+
"last_login": now,
|
| 100 |
+
})
|
| 101 |
|
| 102 |
access_token = AuthService._create_access_token(user.id)
|
| 103 |
raw_refresh, refresh_hash, token_key, expires_at = AuthService._create_refresh_token()
|
| 104 |
|
| 105 |
session_id = _uuid()
|
| 106 |
+
session = RefreshSession(
|
| 107 |
+
id=session_id,
|
| 108 |
+
user_id=user.id,
|
| 109 |
+
token_key=token_key,
|
| 110 |
+
token_hash=refresh_hash,
|
| 111 |
+
device_info=request.headers.get("User-Agent", "Unknown"),
|
| 112 |
+
ip_address=request.client.host if request.client else "Unknown",
|
| 113 |
+
expires_at=expires_at,
|
| 114 |
+
created_at=now,
|
| 115 |
+
updated_at=now,
|
| 116 |
)
|
| 117 |
+
await repo.create_refresh_session(session)
|
| 118 |
|
| 119 |
return TokenData(access_token=access_token, refresh_token=raw_refresh)
|
| 120 |
|
| 121 |
@staticmethod
|
| 122 |
+
async def refresh(db: SupabaseClient, raw_refresh_token: str) -> TokenData:
|
| 123 |
+
repo = AuthRepository(db)
|
| 124 |
key = _token_key(raw_refresh_token)
|
| 125 |
|
| 126 |
+
session = await repo.find_valid_session(key)
|
| 127 |
+
if not session:
|
| 128 |
+
comp = await repo.find_session_by_token_key(key)
|
| 129 |
+
if comp and comp.revoked_at is not None:
|
| 130 |
+
await repo.revoke_all_user_sessions(comp.user_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
raise HTTPException(
|
| 132 |
status_code=401,
|
| 133 |
detail="Session compromised. All sessions revoked. Please login again.",
|
| 134 |
)
|
| 135 |
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
| 136 |
|
|
|
|
|
|
|
| 137 |
if not AuthService._verify_token(raw_refresh_token, session.token_hash):
|
| 138 |
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
| 139 |
|
| 140 |
+
user = await repo.find_user_by_id(session.user_id)
|
| 141 |
+
if not user or not user.is_active:
|
|
|
|
|
|
|
|
|
|
| 142 |
raise HTTPException(status_code=401, detail="User not found or inactive")
|
|
|
|
| 143 |
|
| 144 |
+
now = _now()
|
| 145 |
+
await repo.revoke_session(session.id)
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
new_access = AuthService._create_access_token(user.id)
|
| 148 |
new_raw_refresh, new_hash, new_key, new_expires = AuthService._create_refresh_token()
|
| 149 |
|
| 150 |
new_session_id = _uuid()
|
| 151 |
+
new_session = RefreshSession(
|
| 152 |
+
id=new_session_id,
|
| 153 |
+
user_id=user.id,
|
| 154 |
+
token_key=new_key,
|
| 155 |
+
token_hash=new_hash,
|
| 156 |
+
device_info=session.device_info,
|
| 157 |
+
ip_address=session.ip_address,
|
| 158 |
+
expires_at=new_expires,
|
| 159 |
+
created_at=now,
|
| 160 |
+
updated_at=now,
|
| 161 |
)
|
| 162 |
+
await repo.create_refresh_session(new_session)
|
| 163 |
|
| 164 |
+
await repo.update_user(user.id, {"last_login": now})
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
return TokenData(access_token=new_access, refresh_token=new_raw_refresh)
|
| 167 |
|
| 168 |
@staticmethod
|
| 169 |
+
async def logout(db: SupabaseClient, user: User, raw_refresh_token: str):
|
| 170 |
+
repo = AuthRepository(db)
|
| 171 |
key = _token_key(raw_refresh_token)
|
| 172 |
+
session = await repo.find_session_by_token_key(key)
|
| 173 |
+
if not session or session.user_id != user.id:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
raise HTTPException(status_code=404, detail="Session not found")
|
| 175 |
+
await repo.revoke_session(session.id)
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
@staticmethod
|
| 178 |
+
async def logout_all(db: SupabaseClient, user: User):
|
| 179 |
+
repo = AuthRepository(db)
|
| 180 |
+
await repo.revoke_all_user_sessions(user.id)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
@staticmethod
|
| 183 |
+
async def change_password(db: SupabaseClient, user: User, schema: ChangePasswordSchema):
|
| 184 |
if not AuthService._verify_password(schema.current_password, user.password_hash):
|
| 185 |
raise HTTPException(status_code=400, detail="Incorrect current password")
|
| 186 |
+
repo = AuthRepository(db)
|
| 187 |
+
now = _now()
|
| 188 |
+
await repo.update_user(user.id, {
|
| 189 |
+
"password_hash": ph.hash(schema.new_password),
|
| 190 |
+
"password_changed_at": now,
|
| 191 |
+
})
|
| 192 |
|
| 193 |
@staticmethod
|
| 194 |
+
async def forgot_password(db: SupabaseClient, schema: ForgotPasswordSchema):
|
| 195 |
+
repo = AuthRepository(db)
|
| 196 |
+
user = await repo.find_user_by_email(schema.email)
|
| 197 |
+
if user:
|
|
|
|
|
|
|
| 198 |
reset_token = jwt.encode(
|
| 199 |
{
|
| 200 |
+
"sub": user.id,
|
| 201 |
"type": "reset_password",
|
| 202 |
"exp": _now() + timedelta(hours=1),
|
| 203 |
},
|
|
|
|
| 207 |
logger.info("Password reset token for %s: %s", schema.email, reset_token)
|
| 208 |
|
| 209 |
@staticmethod
|
| 210 |
+
async def reset_password(db: SupabaseClient, schema: ResetPasswordSchema):
|
| 211 |
try:
|
| 212 |
payload = jwt.decode(
|
| 213 |
schema.token,
|
|
|
|
| 220 |
except jwt.PyJWTError:
|
| 221 |
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
|
| 222 |
|
| 223 |
+
repo = AuthRepository(db)
|
| 224 |
+
user = await repo.find_user_by_id(user_id)
|
| 225 |
+
if not user:
|
| 226 |
raise HTTPException(status_code=404, detail="User not found")
|
| 227 |
|
| 228 |
+
now = _now()
|
| 229 |
+
await repo.update_user(user_id, {
|
| 230 |
+
"password_hash": ph.hash(schema.new_password),
|
| 231 |
+
"password_changed_at": now,
|
| 232 |
+
})
|
| 233 |
+
await repo.revoke_all_user_sessions(user_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
|
| 235 |
@staticmethod
|
| 236 |
+
async def update_profile(db: SupabaseClient, user: User, schema: UpdateProfileSchema) -> User:
|
| 237 |
+
repo = AuthRepository(db)
|
| 238 |
if schema.username is not None:
|
| 239 |
+
existing = await repo.find_user_by_username(schema.username)
|
| 240 |
+
if existing and existing.id != user.id:
|
|
|
|
|
|
|
|
|
|
| 241 |
raise HTTPException(status_code=409, detail="Username already taken")
|
| 242 |
+
await repo.update_user(user.id, {"username": schema.username})
|
| 243 |
if schema.full_name is not None:
|
| 244 |
+
await repo.update_user(user.id, {"full_name": schema.full_name})
|
| 245 |
+
return await repo.find_user_by_id(user.id)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
@staticmethod
|
| 248 |
+
async def soft_delete(db: SupabaseClient, user: User):
|
| 249 |
+
repo = AuthRepository(db)
|
| 250 |
+
now = _now()
|
| 251 |
+
await repo.update_user(user.id, {
|
| 252 |
+
"deleted_at": now,
|
| 253 |
+
"is_active": False,
|
| 254 |
+
})
|
| 255 |
+
await repo.revoke_all_user_sessions(user.id)
|
|
|
|
|
|
|
|
|
|
| 256 |
|
| 257 |
@staticmethod
|
| 258 |
+
async def list_sessions(db: SupabaseClient, user: User) -> list[RefreshSession]:
|
| 259 |
+
repo = AuthRepository(db)
|
| 260 |
+
return await repo.find_user_sessions(user.id)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
|
| 262 |
@staticmethod
|
| 263 |
+
async def revoke_session(db: SupabaseClient, user: User, session_id: str):
|
| 264 |
+
repo = AuthRepository(db)
|
| 265 |
+
sessions = await repo.find_user_sessions(user.id)
|
| 266 |
+
target = [s for s in sessions if s.id == session_id]
|
| 267 |
+
if not target:
|
|
|
|
| 268 |
raise HTTPException(status_code=404, detail="Session not found")
|
| 269 |
+
await repo.revoke_session(session_id)
|
|
|
|
|
|
|
|
|
|
| 270 |
|
| 271 |
@staticmethod
|
| 272 |
+
async def cleanup_expired_sessions():
|
| 273 |
+
from app.services.supabase import get_supabase_client
|
| 274 |
+
client = get_supabase_client()
|
| 275 |
+
if client:
|
| 276 |
+
repo = AuthRepository(client)
|
| 277 |
+
count = await repo.cleanup_expired_sessions()
|
| 278 |
+
if count:
|
| 279 |
+
logger.info("Cleaned up %s expired sessions", count)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
|
| 281 |
@staticmethod
|
| 282 |
def _create_access_token(user_id: str) -> str:
|
|
|
|
| 315 |
return False
|
| 316 |
|
| 317 |
@staticmethod
|
| 318 |
+
async def user_to_profile(db: SupabaseClient, user: User) -> UserProfile:
|
| 319 |
+
roles: list[str] = []
|
| 320 |
if db:
|
| 321 |
try:
|
| 322 |
+
repo = AuthRepository(db)
|
| 323 |
+
roles = await repo.get_user_roles(user.id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
except Exception:
|
| 325 |
pass
|
| 326 |
return UserProfile(
|
app/services/supabase/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.services.supabase.client import SupabaseClient, get_supabase_client, set_supabase_client
|
| 2 |
+
from app.services.supabase.repositories import (
|
| 3 |
+
AuthRepository,
|
| 4 |
+
VectorStoreRepository,
|
| 5 |
+
)
|
| 6 |
+
|
| 7 |
+
__all__ = [
|
| 8 |
+
"SupabaseClient",
|
| 9 |
+
"get_supabase_client",
|
| 10 |
+
"set_supabase_client",
|
| 11 |
+
"AuthRepository",
|
| 12 |
+
"VectorStoreRepository",
|
| 13 |
+
]
|
app/services/supabase/client.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import logging
|
| 5 |
+
from typing import Any, Dict, List, Optional
|
| 6 |
+
|
| 7 |
+
from supabase import create_client, Client
|
| 8 |
+
|
| 9 |
+
from app.config import get_settings
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class SupabaseClient:
|
| 15 |
+
def __init__(self, url: str, service_key: str):
|
| 16 |
+
self._url = url
|
| 17 |
+
self._service_key = service_key
|
| 18 |
+
self._client: Optional[Client] = None
|
| 19 |
+
|
| 20 |
+
async def initialize(self):
|
| 21 |
+
self._client = await asyncio.to_thread(create_client, self._url, self._service_key)
|
| 22 |
+
logger.info("Supabase client initialized")
|
| 23 |
+
|
| 24 |
+
@property
|
| 25 |
+
def client(self) -> Client:
|
| 26 |
+
if self._client is None:
|
| 27 |
+
raise RuntimeError("Supabase client not initialized. Call initialize() first.")
|
| 28 |
+
return self._client
|
| 29 |
+
|
| 30 |
+
async def close(self):
|
| 31 |
+
self._client = None
|
| 32 |
+
set_supabase_client(None)
|
| 33 |
+
logger.info("Supabase client closed")
|
| 34 |
+
|
| 35 |
+
# --- Table operations ---
|
| 36 |
+
|
| 37 |
+
async def select(
|
| 38 |
+
self, table: str, columns: str = "*",
|
| 39 |
+
eq: Optional[tuple[str, Any]] = None,
|
| 40 |
+
order: Optional[tuple[str, bool]] = None,
|
| 41 |
+
limit: Optional[int] = None,
|
| 42 |
+
offset: Optional[int] = None,
|
| 43 |
+
) -> List[Dict[str, Any]]:
|
| 44 |
+
query = self.client.table(table).select(columns)
|
| 45 |
+
if eq:
|
| 46 |
+
query = query.eq(eq[0], eq[1])
|
| 47 |
+
if order:
|
| 48 |
+
query = query.order(order[0], desc=order[1])
|
| 49 |
+
if limit:
|
| 50 |
+
query = query.limit(limit)
|
| 51 |
+
if offset:
|
| 52 |
+
query = query.offset(offset)
|
| 53 |
+
result = await asyncio.to_thread(query.execute)
|
| 54 |
+
return result.data if result else []
|
| 55 |
+
|
| 56 |
+
async def select_in(
|
| 57 |
+
self, table: str, column: str, values: List[Any],
|
| 58 |
+
columns: str = "*",
|
| 59 |
+
) -> List[Dict[str, Any]]:
|
| 60 |
+
query = self.client.table(table).select(columns).in_(column, values)
|
| 61 |
+
result = await asyncio.to_thread(query.execute)
|
| 62 |
+
return result.data if result else []
|
| 63 |
+
|
| 64 |
+
async def insert(
|
| 65 |
+
self, table: str, data: Dict[str, Any],
|
| 66 |
+
returning: str = "representation",
|
| 67 |
+
) -> Optional[Dict[str, Any]]:
|
| 68 |
+
query = self.client.table(table).insert(data, returning=returning)
|
| 69 |
+
result = await asyncio.to_thread(query.execute)
|
| 70 |
+
if result and result.data:
|
| 71 |
+
return result.data[0]
|
| 72 |
+
return None
|
| 73 |
+
|
| 74 |
+
async def update(
|
| 75 |
+
self, table: str, column: str, value: Any,
|
| 76 |
+
data: Dict[str, Any],
|
| 77 |
+
) -> List[Dict[str, Any]]:
|
| 78 |
+
query = self.client.table(table).update(data).eq(column, value)
|
| 79 |
+
result = await asyncio.to_thread(query.execute)
|
| 80 |
+
return result.data if result else []
|
| 81 |
+
|
| 82 |
+
async def delete(
|
| 83 |
+
self, table: str, column: str, value: Any,
|
| 84 |
+
) -> List[Dict[str, Any]]:
|
| 85 |
+
query = self.client.table(table).delete().eq(column, value)
|
| 86 |
+
result = await asyncio.to_thread(query.execute)
|
| 87 |
+
return result.data if result else []
|
| 88 |
+
|
| 89 |
+
async def upsert(
|
| 90 |
+
self, table: str, data: Dict[str, Any],
|
| 91 |
+
on_conflict: str = "id",
|
| 92 |
+
) -> Optional[Dict[str, Any]]:
|
| 93 |
+
query = self.client.table(table).upsert(data, on_conflict=on_conflict)
|
| 94 |
+
result = await asyncio.to_thread(query.execute)
|
| 95 |
+
if result and result.data:
|
| 96 |
+
return result.data[0]
|
| 97 |
+
return None
|
| 98 |
+
|
| 99 |
+
async def execute_sql(self, sql: str) -> Any:
|
| 100 |
+
result = await asyncio.to_thread(
|
| 101 |
+
self.client.rpc,
|
| 102 |
+
"exec_sql",
|
| 103 |
+
{"sql": sql},
|
| 104 |
+
)
|
| 105 |
+
return result
|
| 106 |
+
|
| 107 |
+
async def find_one(
|
| 108 |
+
self, table: str, column: str, value: Any,
|
| 109 |
+
columns: str = "*",
|
| 110 |
+
) -> Optional[Dict[str, Any]]:
|
| 111 |
+
rows = await self.select(table, columns=columns, eq=(column, value), limit=1)
|
| 112 |
+
return rows[0] if rows else None
|
| 113 |
+
|
| 114 |
+
async def find_all(
|
| 115 |
+
self, table: str, column: str, value: Any,
|
| 116 |
+
columns: str = "*",
|
| 117 |
+
) -> List[Dict[str, Any]]:
|
| 118 |
+
return await self.select(table, columns=columns, eq=(column, value))
|
| 119 |
+
|
| 120 |
+
async def count(
|
| 121 |
+
self, table: str, column: str, value: Any,
|
| 122 |
+
) -> int:
|
| 123 |
+
query = self.client.table(table).select("*", count="exact").eq(column, value)
|
| 124 |
+
result = await asyncio.to_thread(query.execute)
|
| 125 |
+
return result.count if hasattr(result, "count") and result.count else 0
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
_client_instance: Optional[SupabaseClient] = None
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def get_supabase_client() -> Optional[SupabaseClient]:
|
| 132 |
+
global _client_instance
|
| 133 |
+
return _client_instance
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def set_supabase_client(client: SupabaseClient):
|
| 137 |
+
global _client_instance
|
| 138 |
+
_client_instance = client
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
async def create_supabase_client() -> SupabaseClient:
|
| 142 |
+
settings = get_settings()
|
| 143 |
+
url = settings.supabase_url
|
| 144 |
+
key = settings.supabase_service_role_key
|
| 145 |
+
if not url or not key:
|
| 146 |
+
logger.warning("Supabase not configured (SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY missing)")
|
| 147 |
+
return None
|
| 148 |
+
client = SupabaseClient(url, key)
|
| 149 |
+
await client.initialize()
|
| 150 |
+
set_supabase_client(client)
|
| 151 |
+
return client
|
app/services/supabase/repositories.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
from typing import Any, Dict, List, Optional
|
| 7 |
+
|
| 8 |
+
from app.core.auth.models import RefreshSession, User, _uuid
|
| 9 |
+
from app.core.auth.models import _utcnow as _now
|
| 10 |
+
from app.core.vector_store.models import VectorStoreIndex
|
| 11 |
+
from app.services.supabase.client import SupabaseClient
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _serialize_dt(dt: Optional[datetime]) -> Optional[str]:
|
| 17 |
+
if dt is None:
|
| 18 |
+
return None
|
| 19 |
+
return dt.isoformat()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _deserialize_dt(val) -> Optional[datetime]:
|
| 23 |
+
if val is None:
|
| 24 |
+
return None
|
| 25 |
+
if isinstance(val, datetime):
|
| 26 |
+
return val
|
| 27 |
+
if isinstance(val, str):
|
| 28 |
+
return datetime.fromisoformat(val)
|
| 29 |
+
return val
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _row_to_user(row: dict) -> User:
|
| 33 |
+
return User(
|
| 34 |
+
id=row["id"],
|
| 35 |
+
email=row["email"],
|
| 36 |
+
username=row.get("username"),
|
| 37 |
+
full_name=row.get("full_name"),
|
| 38 |
+
password_hash=row["password_hash"],
|
| 39 |
+
is_active=bool(row["is_active"]),
|
| 40 |
+
is_verified=bool(row["is_verified"]),
|
| 41 |
+
failed_login_attempts=row["failed_login_attempts"],
|
| 42 |
+
locked_until=_deserialize_dt(row.get("locked_until")),
|
| 43 |
+
last_login=_deserialize_dt(row.get("last_login")),
|
| 44 |
+
password_changed_at=_deserialize_dt(row.get("password_changed_at")),
|
| 45 |
+
created_at=_deserialize_dt(row["created_at"]),
|
| 46 |
+
updated_at=_deserialize_dt(row["updated_at"]),
|
| 47 |
+
deleted_at=_deserialize_dt(row.get("deleted_at")),
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _row_to_refresh_session(row: dict) -> RefreshSession:
|
| 52 |
+
return RefreshSession(
|
| 53 |
+
id=row["id"],
|
| 54 |
+
user_id=row["user_id"],
|
| 55 |
+
token_key=row["token_key"],
|
| 56 |
+
token_hash=row["token_hash"],
|
| 57 |
+
device_info=row.get("device_info"),
|
| 58 |
+
ip_address=row.get("ip_address"),
|
| 59 |
+
expires_at=_deserialize_dt(row["expires_at"]),
|
| 60 |
+
revoked_at=_deserialize_dt(row.get("revoked_at")),
|
| 61 |
+
created_at=_deserialize_dt(row["created_at"]),
|
| 62 |
+
updated_at=_deserialize_dt(row["updated_at"]),
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class AuthRepository:
|
| 67 |
+
def __init__(self, client: SupabaseClient):
|
| 68 |
+
self._client = client
|
| 69 |
+
|
| 70 |
+
async def find_user_by_email(self, email: str) -> Optional[User]:
|
| 71 |
+
row = await self._client.find_one("users", "email", email)
|
| 72 |
+
if row is None or row.get("deleted_at") is not None:
|
| 73 |
+
return None
|
| 74 |
+
return _row_to_user(row)
|
| 75 |
+
|
| 76 |
+
async def find_user_by_id(self, user_id: str) -> Optional[User]:
|
| 77 |
+
row = await self._client.find_one("users", "id", user_id)
|
| 78 |
+
if row is None:
|
| 79 |
+
return None
|
| 80 |
+
return _row_to_user(row)
|
| 81 |
+
|
| 82 |
+
async def find_user_by_username(self, username: str) -> Optional[User]:
|
| 83 |
+
row = await self._client.find_one("users", "username", username)
|
| 84 |
+
if row is None or row.get("deleted_at") is not None:
|
| 85 |
+
return None
|
| 86 |
+
return _row_to_user(row)
|
| 87 |
+
|
| 88 |
+
async def create_user(self, user: User) -> User:
|
| 89 |
+
data = {
|
| 90 |
+
"id": user.id,
|
| 91 |
+
"email": user.email,
|
| 92 |
+
"username": user.username,
|
| 93 |
+
"full_name": user.full_name,
|
| 94 |
+
"password_hash": user.password_hash,
|
| 95 |
+
"is_active": user.is_active,
|
| 96 |
+
"is_verified": user.is_verified,
|
| 97 |
+
"failed_login_attempts": user.failed_login_attempts,
|
| 98 |
+
"locked_until": _serialize_dt(user.locked_until),
|
| 99 |
+
"last_login": _serialize_dt(user.last_login),
|
| 100 |
+
"password_changed_at": _serialize_dt(user.password_changed_at),
|
| 101 |
+
"created_at": _serialize_dt(user.created_at),
|
| 102 |
+
"updated_at": _serialize_dt(user.updated_at),
|
| 103 |
+
"deleted_at": _serialize_dt(user.deleted_at),
|
| 104 |
+
}
|
| 105 |
+
await self._client.insert("users", data)
|
| 106 |
+
return user
|
| 107 |
+
|
| 108 |
+
async def update_user(self, user_id: str, updates: Dict[str, Any]) -> None:
|
| 109 |
+
data = {}
|
| 110 |
+
for k, v in updates.items():
|
| 111 |
+
if isinstance(v, datetime):
|
| 112 |
+
data[k] = _serialize_dt(v)
|
| 113 |
+
else:
|
| 114 |
+
data[k] = v
|
| 115 |
+
data["updated_at"] = _serialize_dt(_now())
|
| 116 |
+
await self._client.update("users", "id", user_id, data)
|
| 117 |
+
|
| 118 |
+
async def get_role_by_name(self, name: str) -> Optional[Dict[str, Any]]:
|
| 119 |
+
return await self._client.find_one("roles", "name", name)
|
| 120 |
+
|
| 121 |
+
async def assign_role(self, user_id: str, role_id: str) -> None:
|
| 122 |
+
await self._client.insert("user_roles", {
|
| 123 |
+
"user_id": user_id,
|
| 124 |
+
"role_id": role_id,
|
| 125 |
+
})
|
| 126 |
+
|
| 127 |
+
async def get_user_roles(self, user_id: str) -> List[str]:
|
| 128 |
+
rows = await self._client.select(
|
| 129 |
+
"roles",
|
| 130 |
+
columns="name",
|
| 131 |
+
eq=("user_id", user_id),
|
| 132 |
+
)
|
| 133 |
+
return [r["name"] for r in rows if "name" in r]
|
| 134 |
+
|
| 135 |
+
async def get_user_permissions(self, user_id: str) -> List[str]:
|
| 136 |
+
rows = await self._client.select_in(
|
| 137 |
+
"permissions",
|
| 138 |
+
"id",
|
| 139 |
+
[],
|
| 140 |
+
columns="code",
|
| 141 |
+
)
|
| 142 |
+
return [r["code"] for r in rows if "code" in r]
|
| 143 |
+
|
| 144 |
+
async def create_refresh_session(self, session: RefreshSession) -> None:
|
| 145 |
+
data = {
|
| 146 |
+
"id": session.id,
|
| 147 |
+
"user_id": session.user_id,
|
| 148 |
+
"token_key": session.token_key,
|
| 149 |
+
"token_hash": session.token_hash,
|
| 150 |
+
"device_info": session.device_info,
|
| 151 |
+
"ip_address": session.ip_address,
|
| 152 |
+
"expires_at": _serialize_dt(session.expires_at),
|
| 153 |
+
"revoked_at": _serialize_dt(session.revoked_at),
|
| 154 |
+
"created_at": _serialize_dt(session.created_at),
|
| 155 |
+
"updated_at": _serialize_dt(session.updated_at),
|
| 156 |
+
}
|
| 157 |
+
await self._client.insert("refresh_sessions", data)
|
| 158 |
+
|
| 159 |
+
async def find_session_by_token_key(self, token_key: str) -> Optional[RefreshSession]:
|
| 160 |
+
row = await self._client.find_one("refresh_sessions", "token_key", token_key)
|
| 161 |
+
if row is None:
|
| 162 |
+
return None
|
| 163 |
+
return _row_to_refresh_session(row)
|
| 164 |
+
|
| 165 |
+
async def find_valid_session(self, token_key: str) -> Optional[RefreshSession]:
|
| 166 |
+
rows = await self._client.select(
|
| 167 |
+
"refresh_sessions",
|
| 168 |
+
eq=("token_key", token_key),
|
| 169 |
+
)
|
| 170 |
+
now = _serialize_dt(_now())
|
| 171 |
+
for row in rows:
|
| 172 |
+
if row.get("revoked_at") is None and row.get("expires_at", "") > now:
|
| 173 |
+
return _row_to_refresh_session(row)
|
| 174 |
+
return None
|
| 175 |
+
|
| 176 |
+
async def revoke_session(self, session_id: str) -> None:
|
| 177 |
+
now = _serialize_dt(_now())
|
| 178 |
+
await self._client.update("refresh_sessions", "id", session_id, {"revoked_at": now})
|
| 179 |
+
|
| 180 |
+
async def revoke_all_user_sessions(self, user_id: str) -> None:
|
| 181 |
+
now = _serialize_dt(_now())
|
| 182 |
+
rows = await self._client.select(
|
| 183 |
+
"refresh_sessions",
|
| 184 |
+
eq=("user_id", user_id),
|
| 185 |
+
)
|
| 186 |
+
for row in rows:
|
| 187 |
+
if row.get("revoked_at") is None:
|
| 188 |
+
await self._client.update(
|
| 189 |
+
"refresh_sessions", "id", row["id"], {"revoked_at": now},
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
async def find_user_sessions(self, user_id: str) -> List[RefreshSession]:
|
| 193 |
+
rows = await self._client.select(
|
| 194 |
+
"refresh_sessions",
|
| 195 |
+
eq=("user_id", user_id),
|
| 196 |
+
order=("created_at", True),
|
| 197 |
+
)
|
| 198 |
+
return [_row_to_refresh_session(r) for r in rows if r.get("revoked_at") is None]
|
| 199 |
+
|
| 200 |
+
async def cleanup_expired_sessions(self) -> int:
|
| 201 |
+
rows = await self._client.select("refresh_sessions")
|
| 202 |
+
count = 0
|
| 203 |
+
now = _serialize_dt(_now())
|
| 204 |
+
for row in rows:
|
| 205 |
+
if row.get("expires_at", "") < now and row.get("revoked_at") is None:
|
| 206 |
+
await self._client.update(
|
| 207 |
+
"refresh_sessions", "id", row["id"], {"revoked_at": now},
|
| 208 |
+
)
|
| 209 |
+
count += 1
|
| 210 |
+
return count
|
| 211 |
+
|
| 212 |
+
async def get_user_permission_codes(self, user_id: str) -> List[str]:
|
| 213 |
+
return await self._client.select_in(
|
| 214 |
+
"permissions",
|
| 215 |
+
"id",
|
| 216 |
+
[],
|
| 217 |
+
columns="code",
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
class VectorStoreRepository:
|
| 222 |
+
def __init__(self, client: SupabaseClient):
|
| 223 |
+
self._client = client
|
| 224 |
+
|
| 225 |
+
async def find_all(self) -> List[VectorStoreIndex]:
|
| 226 |
+
rows = await self._client.select("vector_store_index", order=("created_at", False))
|
| 227 |
+
return [VectorStoreIndex.from_dict(r) for r in rows]
|
| 228 |
+
|
| 229 |
+
async def find_by_id(self, store_id: str) -> Optional[VectorStoreIndex]:
|
| 230 |
+
row = await self._client.find_one("vector_store_index", "store_id", store_id)
|
| 231 |
+
if row is None:
|
| 232 |
+
return None
|
| 233 |
+
return VectorStoreIndex.from_dict(row)
|
| 234 |
+
|
| 235 |
+
async def upsert(self, index: VectorStoreIndex) -> None:
|
| 236 |
+
data = {
|
| 237 |
+
"store_id": index.store_id,
|
| 238 |
+
"name": index.name,
|
| 239 |
+
"path": index.path,
|
| 240 |
+
"description": index.description,
|
| 241 |
+
"metadata_json": index.metadata_json,
|
| 242 |
+
"created_at": index.created_at,
|
| 243 |
+
}
|
| 244 |
+
existing = await self._client.find_one("vector_store_index", "store_id", index.store_id)
|
| 245 |
+
if existing:
|
| 246 |
+
await self._client.update("vector_store_index", "store_id", index.store_id, {
|
| 247 |
+
"name": index.name,
|
| 248 |
+
"description": index.description,
|
| 249 |
+
"metadata_json": index.metadata_json,
|
| 250 |
+
})
|
| 251 |
+
else:
|
| 252 |
+
await self._client.insert("vector_store_index", data)
|
| 253 |
+
|
| 254 |
+
async def delete(self, store_id: str) -> None:
|
| 255 |
+
await self._client.delete("vector_store_index", "store_id", store_id)
|
app/services/url_shortener_service.py
CHANGED
|
@@ -20,7 +20,7 @@ from urllib.parse import urlparse
|
|
| 20 |
|
| 21 |
from app.config import get_settings
|
| 22 |
from app.core.logger import get_logger
|
| 23 |
-
from app.
|
| 24 |
|
| 25 |
logger = get_logger(__name__)
|
| 26 |
|
|
@@ -313,115 +313,22 @@ def _fire_click_webhook(webhook_url: str, short_code: str, long_url: str,
|
|
| 313 |
|
| 314 |
|
| 315 |
# ---------------------------------------------------------------------------
|
| 316 |
-
#
|
| 317 |
# ---------------------------------------------------------------------------
|
| 318 |
|
| 319 |
-
US_TABLES: dict[str, str] = {
|
| 320 |
-
"url_shortener_owners": """
|
| 321 |
-
CREATE TABLE IF NOT EXISTS url_shortener_owners (
|
| 322 |
-
owner_id VARCHAR(64) PRIMARY KEY,
|
| 323 |
-
name VARCHAR(255) NOT NULL,
|
| 324 |
-
api_key_hash VARCHAR(128) NOT NULL,
|
| 325 |
-
plan VARCHAR(32) NOT NULL DEFAULT 'free',
|
| 326 |
-
created_at VARCHAR(64) NOT NULL,
|
| 327 |
-
UNIQUE KEY uk_uso_api_key_hash (api_key_hash)
|
| 328 |
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 329 |
-
""",
|
| 330 |
-
"url_shortener_links": """
|
| 331 |
-
CREATE TABLE IF NOT EXISTS url_shortener_links (
|
| 332 |
-
short_code VARCHAR(32) PRIMARY KEY,
|
| 333 |
-
long_url TEXT NOT NULL,
|
| 334 |
-
owner_id VARCHAR(64) NOT NULL,
|
| 335 |
-
created_at VARCHAR(64) NOT NULL,
|
| 336 |
-
expires_at VARCHAR(64),
|
| 337 |
-
max_clicks INT,
|
| 338 |
-
click_count INT NOT NULL DEFAULT 0,
|
| 339 |
-
last_accessed_at VARCHAR(64),
|
| 340 |
-
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
| 341 |
-
tags VARCHAR(512),
|
| 342 |
-
note TEXT,
|
| 343 |
-
password_hash VARCHAR(128),
|
| 344 |
-
utm_source VARCHAR(512),
|
| 345 |
-
utm_medium VARCHAR(512),
|
| 346 |
-
utm_campaign VARCHAR(512),
|
| 347 |
-
campaign_id VARCHAR(64),
|
| 348 |
-
custom_domain VARCHAR(255),
|
| 349 |
-
fallback_url TEXT,
|
| 350 |
-
webhook_url TEXT,
|
| 351 |
-
geo_targeting TEXT,
|
| 352 |
-
INDEX idx_usl_owner (owner_id),
|
| 353 |
-
INDEX idx_usl_campaign (campaign_id),
|
| 354 |
-
CONSTRAINT fk_usl_owner FOREIGN KEY (owner_id) REFERENCES url_shortener_owners(owner_id)
|
| 355 |
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 356 |
-
""",
|
| 357 |
-
"url_shortener_clicks": """
|
| 358 |
-
CREATE TABLE IF NOT EXISTS url_shortener_clicks (
|
| 359 |
-
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
| 360 |
-
short_code VARCHAR(32) NOT NULL,
|
| 361 |
-
referrer TEXT,
|
| 362 |
-
user_agent TEXT,
|
| 363 |
-
ip_address VARCHAR(45),
|
| 364 |
-
country VARCHAR(64),
|
| 365 |
-
browser VARCHAR(64),
|
| 366 |
-
device VARCHAR(64),
|
| 367 |
-
os VARCHAR(64),
|
| 368 |
-
clicked_at VARCHAR(64) NOT NULL,
|
| 369 |
-
INDEX idx_usc_code (short_code),
|
| 370 |
-
INDEX idx_usc_clicked_at (clicked_at),
|
| 371 |
-
CONSTRAINT fk_usc_link FOREIGN KEY (short_code) REFERENCES url_shortener_links(short_code)
|
| 372 |
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 373 |
-
""",
|
| 374 |
-
"url_shortener_audit_log": """
|
| 375 |
-
CREATE TABLE IF NOT EXISTS url_shortener_audit_log (
|
| 376 |
-
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
| 377 |
-
timestamp VARCHAR(64) NOT NULL,
|
| 378 |
-
owner_id VARCHAR(64),
|
| 379 |
-
action VARCHAR(64) NOT NULL,
|
| 380 |
-
short_code VARCHAR(32),
|
| 381 |
-
detail TEXT
|
| 382 |
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 383 |
-
""",
|
| 384 |
-
"url_shortener_campaigns": """
|
| 385 |
-
CREATE TABLE IF NOT EXISTS url_shortener_campaigns (
|
| 386 |
-
campaign_id VARCHAR(64) PRIMARY KEY,
|
| 387 |
-
owner_id VARCHAR(64) NOT NULL,
|
| 388 |
-
name VARCHAR(255) NOT NULL,
|
| 389 |
-
description TEXT,
|
| 390 |
-
created_at VARCHAR(64) NOT NULL,
|
| 391 |
-
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
| 392 |
-
INDEX idx_uscamp_owner (owner_id),
|
| 393 |
-
CONSTRAINT fk_uscamp_owner FOREIGN KEY (owner_id) REFERENCES url_shortener_owners(owner_id)
|
| 394 |
-
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
| 395 |
-
""",
|
| 396 |
-
}
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
def _run_async(coro):
|
| 400 |
-
try:
|
| 401 |
-
loop = asyncio.get_running_loop()
|
| 402 |
-
if loop.is_running():
|
| 403 |
-
import concurrent.futures
|
| 404 |
-
with concurrent.futures.ThreadPoolExecutor(max_workers=1):
|
| 405 |
-
future = asyncio.run_coroutine_threadsafe(coro, loop)
|
| 406 |
-
return future.result()
|
| 407 |
-
except RuntimeError:
|
| 408 |
-
pass
|
| 409 |
-
return asyncio.run(coro)
|
| 410 |
-
|
| 411 |
|
| 412 |
class Storage:
|
| 413 |
def __init__(self):
|
| 414 |
self._lock = threading.RLock()
|
| 415 |
|
| 416 |
-
def
|
| 417 |
-
|
| 418 |
-
if
|
| 419 |
-
raise RuntimeError("
|
| 420 |
-
return
|
| 421 |
|
| 422 |
def _init_schema(self):
|
| 423 |
-
|
| 424 |
-
_run_async(mgr.ensure_tables(US_TABLES))
|
| 425 |
|
| 426 |
def close(self):
|
| 427 |
pass
|
|
@@ -430,288 +337,263 @@ class Storage:
|
|
| 430 |
|
| 431 |
def create_owner(self, owner_id: str, name: str, api_key_hash: str, plan: str = "free"):
|
| 432 |
with self._lock:
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
"
|
| 436 |
-
|
| 437 |
-
|
|
|
|
|
|
|
|
|
|
| 438 |
|
| 439 |
def get_owner_by_key_hash(self, api_key_hash: str) -> Optional[dict]:
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
(api_key_hash,),
|
| 443 |
-
))
|
| 444 |
|
| 445 |
def get_owner(self, owner_id: str) -> Optional[dict]:
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
(owner_id,),
|
| 449 |
-
))
|
| 450 |
|
| 451 |
def update_owner_plan(self, owner_id: str, plan: str):
|
| 452 |
with self._lock:
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
(plan, owner_id),
|
| 456 |
-
))
|
| 457 |
|
| 458 |
# ---------- Links ----------
|
| 459 |
|
| 460 |
def code_exists(self, short_code: str) -> bool:
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
(short_code,),
|
| 464 |
-
))
|
| 465 |
return row is not None
|
| 466 |
|
| 467 |
def insert_link(self, **kwargs):
|
| 468 |
with self._lock:
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
|
| 483 |
def update_link(self, short_code: str, **updates):
|
| 484 |
with self._lock:
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
_run_async(self._get_mgr().execute(
|
| 488 |
-
f"UPDATE url_shortener_links SET {sets} WHERE short_code = %s",
|
| 489 |
-
tuple(vals),
|
| 490 |
-
))
|
| 491 |
|
| 492 |
def get_link(self, short_code: str) -> Optional[dict]:
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
(short_code,),
|
| 496 |
-
))
|
| 497 |
|
| 498 |
def list_links_for_owner(self, owner_id: str) -> List[dict]:
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
(owner_id,),
|
| 502 |
))
|
| 503 |
|
| 504 |
def deactivate_link(self, short_code: str):
|
| 505 |
with self._lock:
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
(short_code,),
|
| 509 |
-
))
|
| 510 |
|
| 511 |
def record_click(self, short_code: str, referrer: Optional[str] = None,
|
| 512 |
user_agent: Optional[str] = None, ip_address: Optional[str] = None):
|
| 513 |
now = _now_iso()
|
| 514 |
parsed = _parse_user_agent(user_agent or "")
|
| 515 |
with self._lock:
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
"
|
| 519 |
-
"
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
"
|
| 525 |
-
"
|
| 526 |
-
|
| 527 |
-
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 528 |
|
| 529 |
def get_click_analytics(self, short_code: str) -> Dict[str, Any]:
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
(
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
"SELECT os, COUNT(*) AS cnt FROM url_shortener_clicks WHERE short_code = %s "
|
| 554 |
-
"AND os IS NOT NULL GROUP BY os ORDER BY cnt DESC",
|
| 555 |
-
(short_code,),
|
| 556 |
-
))
|
| 557 |
-
recent = _run_async(mgr.fetchall_all(
|
| 558 |
-
"SELECT clicked_at FROM url_shortener_clicks WHERE short_code = %s "
|
| 559 |
-
"ORDER BY clicked_at DESC LIMIT 50",
|
| 560 |
-
(short_code,),
|
| 561 |
-
))
|
| 562 |
return {
|
| 563 |
"total_clicks": total_val,
|
| 564 |
-
"browsers":
|
| 565 |
-
"devices":
|
| 566 |
-
"operating_systems":
|
| 567 |
-
"top_referrers":
|
| 568 |
-
"recent_clicks": [
|
| 569 |
}
|
| 570 |
|
| 571 |
def get_link_count_for_owner(self, owner_id: str) -> int:
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
))
|
| 576 |
-
return row["cnt"] if row else 0
|
| 577 |
|
| 578 |
def audit(self, owner_id: Optional[str], action: str, short_code: Optional[str], detail: str = ""):
|
| 579 |
with self._lock:
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
"
|
| 583 |
-
|
| 584 |
-
|
|
|
|
|
|
|
|
|
|
| 585 |
|
| 586 |
def export_links_csv(self, owner_id: str) -> str:
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
"
|
| 590 |
-
(owner_id,),
|
| 591 |
))
|
| 592 |
buf = io.StringIO()
|
| 593 |
w = csv.writer(buf)
|
| 594 |
w.writerow(["short_code", "long_url", "created_at", "click_count", "is_active", "tags", "note"])
|
| 595 |
for r in rows:
|
| 596 |
w.writerow([r["short_code"], r["long_url"], r["created_at"],
|
| 597 |
-
r["click_count"], r["is_active"], r
|
| 598 |
return buf.getvalue()
|
| 599 |
|
| 600 |
def export_clicks_csv(self, short_code: str) -> str:
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
"
|
| 604 |
-
(short_code,),
|
| 605 |
))
|
| 606 |
buf = io.StringIO()
|
| 607 |
w = csv.writer(buf)
|
| 608 |
w.writerow(["id", "referrer", "user_agent", "ip_address", "browser", "device", "os", "clicked_at"])
|
| 609 |
for r in rows:
|
| 610 |
-
w.writerow([r["id"], r
|
| 611 |
-
r
|
| 612 |
return buf.getvalue()
|
| 613 |
|
| 614 |
def list_links_by_campaign(self, campaign_id: str) -> List[dict]:
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
(campaign_id,),
|
| 618 |
))
|
| 619 |
|
| 620 |
def create_campaign(self, campaign_id: str, owner_id: str, name: str, description: Optional[str] = None):
|
| 621 |
with self._lock:
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
"
|
| 625 |
-
|
| 626 |
-
|
|
|
|
|
|
|
|
|
|
| 627 |
|
| 628 |
def get_campaign(self, campaign_id: str) -> Optional[dict]:
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
(campaign_id,),
|
| 632 |
-
))
|
| 633 |
|
| 634 |
def list_campaigns_for_owner(self, owner_id: str) -> List[dict]:
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
(owner_id,),
|
| 638 |
))
|
| 639 |
|
| 640 |
def deactivate_campaign(self, campaign_id: str):
|
| 641 |
with self._lock:
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
(campaign_id,),
|
| 645 |
-
))
|
| 646 |
|
| 647 |
def get_campaign_link_count(self, campaign_id: str) -> int:
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
))
|
| 652 |
-
return row["cnt"] if row else 0
|
| 653 |
|
| 654 |
def get_campaign_total_clicks(self, campaign_id: str) -> int:
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
))
|
| 659 |
-
return row["cnt"] if row else 0
|
| 660 |
|
| 661 |
def get_campaign_analytics(self, campaign_id: str) -> Dict[str, Any]:
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
))
|
| 666 |
total = 0
|
| 667 |
browsers: Dict[str, int] = {}
|
| 668 |
devices: Dict[str, int] = {}
|
| 669 |
os_data: Dict[str, int] = {}
|
| 670 |
-
mgr = self._get_mgr()
|
| 671 |
for row in codes:
|
| 672 |
code = row["short_code"]
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
"AND device IS NOT NULL GROUP BY device", (code,),
|
| 686 |
-
)):
|
| 687 |
-
devices[d["device"]] = devices.get(d["device"], 0) + d["cnt"]
|
| 688 |
-
for o in _run_async(mgr.fetchall_all(
|
| 689 |
-
"SELECT os, COUNT(*) AS cnt FROM url_shortener_clicks WHERE short_code = %s "
|
| 690 |
-
"AND os IS NOT NULL GROUP BY os", (code,),
|
| 691 |
-
)):
|
| 692 |
-
os_data[o["os"]] = os_data.get(o["os"], 0) + o["cnt"]
|
| 693 |
return {"total_clicks": total, "browsers": browsers, "devices": devices, "operating_systems": os_data}
|
| 694 |
|
| 695 |
def owner_summary(self, owner_id: str) -> Dict[str, Any]:
|
| 696 |
row = self.get_owner(owner_id)
|
| 697 |
if row is None:
|
| 698 |
return {}
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
))
|
| 704 |
-
|
| 705 |
-
active = _run_async(self._get_mgr().fetchone_all(
|
| 706 |
-
"SELECT COUNT(*) AS cnt FROM url_shortener_links WHERE owner_id = %s AND is_active = 1",
|
| 707 |
-
(owner_id,),
|
| 708 |
-
))
|
| 709 |
return {
|
| 710 |
"owner_id": owner_id,
|
| 711 |
"name": row["name"],
|
| 712 |
"plan": row["plan"],
|
| 713 |
"total_links": link_count,
|
| 714 |
-
"active_links": active
|
| 715 |
"total_clicks": total_clicks,
|
| 716 |
"created_at": row["created_at"],
|
| 717 |
}
|
|
@@ -721,7 +603,7 @@ class URLShortenerService:
|
|
| 721 |
def __init__(self):
|
| 722 |
self.storage = Storage()
|
| 723 |
self.storage._init_schema()
|
| 724 |
-
logger.info("URLShortenerService initialized (
|
| 725 |
|
| 726 |
def close(self) -> None:
|
| 727 |
self.storage.close()
|
|
@@ -1067,15 +949,14 @@ class URLShortenerService:
|
|
| 1067 |
raise LinkNotFoundError(f"No link found for code '{short_code}'")
|
| 1068 |
if row["owner_id"] != owner_id:
|
| 1069 |
raise AuthorizationError("You do not own this link")
|
| 1070 |
-
|
| 1071 |
-
|
|
|
|
| 1072 |
return []
|
| 1073 |
-
rows =
|
| 1074 |
-
"
|
| 1075 |
-
"FROM url_shortener_clicks WHERE short_code = %s ORDER BY clicked_at DESC LIMIT %s",
|
| 1076 |
-
(short_code, limit),
|
| 1077 |
))
|
| 1078 |
-
return list(rows)
|
| 1079 |
|
| 1080 |
def get_qr_code(self, short_code: str) -> Dict[str, str]:
|
| 1081 |
row = self.storage.get_link(short_code)
|
|
|
|
| 20 |
|
| 21 |
from app.config import get_settings
|
| 22 |
from app.core.logger import get_logger
|
| 23 |
+
from app.services.supabase import SupabaseClient, get_supabase_client
|
| 24 |
|
| 25 |
logger = get_logger(__name__)
|
| 26 |
|
|
|
|
| 313 |
|
| 314 |
|
| 315 |
# ---------------------------------------------------------------------------
|
| 316 |
+
# Supabase-backed Storage
|
| 317 |
# ---------------------------------------------------------------------------
|
| 318 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
|
| 320 |
class Storage:
|
| 321 |
def __init__(self):
|
| 322 |
self._lock = threading.RLock()
|
| 323 |
|
| 324 |
+
def _get_client(self) -> SupabaseClient:
|
| 325 |
+
client = get_supabase_client()
|
| 326 |
+
if client is None:
|
| 327 |
+
raise RuntimeError("Supabase client not initialized")
|
| 328 |
+
return client
|
| 329 |
|
| 330 |
def _init_schema(self):
|
| 331 |
+
logger.info("URL shortener schema managed via Supabase (DDL in /ddl directory)")
|
|
|
|
| 332 |
|
| 333 |
def close(self):
|
| 334 |
pass
|
|
|
|
| 337 |
|
| 338 |
def create_owner(self, owner_id: str, name: str, api_key_hash: str, plan: str = "free"):
|
| 339 |
with self._lock:
|
| 340 |
+
import asyncio
|
| 341 |
+
asyncio.run(self._get_client().insert("url_shortener_owners", {
|
| 342 |
+
"owner_id": owner_id,
|
| 343 |
+
"name": name,
|
| 344 |
+
"api_key_hash": api_key_hash,
|
| 345 |
+
"plan": plan,
|
| 346 |
+
"created_at": _now_iso(),
|
| 347 |
+
}))
|
| 348 |
|
| 349 |
def get_owner_by_key_hash(self, api_key_hash: str) -> Optional[dict]:
|
| 350 |
+
import asyncio
|
| 351 |
+
return asyncio.run(self._get_client().find_one("url_shortener_owners", "api_key_hash", api_key_hash))
|
|
|
|
|
|
|
| 352 |
|
| 353 |
def get_owner(self, owner_id: str) -> Optional[dict]:
|
| 354 |
+
import asyncio
|
| 355 |
+
return asyncio.run(self._get_client().find_one("url_shortener_owners", "owner_id", owner_id))
|
|
|
|
|
|
|
| 356 |
|
| 357 |
def update_owner_plan(self, owner_id: str, plan: str):
|
| 358 |
with self._lock:
|
| 359 |
+
import asyncio
|
| 360 |
+
asyncio.run(self._get_client().update("url_shortener_owners", "owner_id", owner_id, {"plan": plan}))
|
|
|
|
|
|
|
| 361 |
|
| 362 |
# ---------- Links ----------
|
| 363 |
|
| 364 |
def code_exists(self, short_code: str) -> bool:
|
| 365 |
+
import asyncio
|
| 366 |
+
row = asyncio.run(self._get_client().find_one("url_shortener_links", "short_code", short_code, columns="short_code"))
|
|
|
|
|
|
|
| 367 |
return row is not None
|
| 368 |
|
| 369 |
def insert_link(self, **kwargs):
|
| 370 |
with self._lock:
|
| 371 |
+
data = {
|
| 372 |
+
"short_code": kwargs["short_code"],
|
| 373 |
+
"long_url": kwargs["long_url"],
|
| 374 |
+
"owner_id": kwargs["owner_id"],
|
| 375 |
+
"created_at": kwargs.get("created_at", _now_iso()),
|
| 376 |
+
"expires_at": kwargs.get("expires_at"),
|
| 377 |
+
"max_clicks": kwargs.get("max_clicks"),
|
| 378 |
+
"click_count": 0,
|
| 379 |
+
"is_active": 1,
|
| 380 |
+
"tags": kwargs.get("tags"),
|
| 381 |
+
"note": kwargs.get("note"),
|
| 382 |
+
"password_hash": kwargs.get("password_hash"),
|
| 383 |
+
"utm_source": kwargs.get("utm_source"),
|
| 384 |
+
"utm_medium": kwargs.get("utm_medium"),
|
| 385 |
+
"utm_campaign": kwargs.get("utm_campaign"),
|
| 386 |
+
"campaign_id": kwargs.get("campaign_id"),
|
| 387 |
+
"custom_domain": kwargs.get("custom_domain"),
|
| 388 |
+
"fallback_url": kwargs.get("fallback_url"),
|
| 389 |
+
"webhook_url": kwargs.get("webhook_url"),
|
| 390 |
+
"geo_targeting": kwargs.get("geo_targeting"),
|
| 391 |
+
}
|
| 392 |
+
import asyncio
|
| 393 |
+
asyncio.run(self._get_client().insert("url_shortener_links", data))
|
| 394 |
|
| 395 |
def update_link(self, short_code: str, **updates):
|
| 396 |
with self._lock:
|
| 397 |
+
import asyncio
|
| 398 |
+
asyncio.run(self._get_client().update("url_shortener_links", "short_code", short_code, updates))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
|
| 400 |
def get_link(self, short_code: str) -> Optional[dict]:
|
| 401 |
+
import asyncio
|
| 402 |
+
return asyncio.run(self._get_client().find_one("url_shortener_links", "short_code", short_code))
|
|
|
|
|
|
|
| 403 |
|
| 404 |
def list_links_for_owner(self, owner_id: str) -> List[dict]:
|
| 405 |
+
import asyncio
|
| 406 |
+
return asyncio.run(self._get_client().select(
|
| 407 |
+
"url_shortener_links", eq=("owner_id", owner_id), order=("created_at", True),
|
| 408 |
))
|
| 409 |
|
| 410 |
def deactivate_link(self, short_code: str):
|
| 411 |
with self._lock:
|
| 412 |
+
import asyncio
|
| 413 |
+
asyncio.run(self._get_client().update("url_shortener_links", "short_code", short_code, {"is_active": 0}))
|
|
|
|
|
|
|
| 414 |
|
| 415 |
def record_click(self, short_code: str, referrer: Optional[str] = None,
|
| 416 |
user_agent: Optional[str] = None, ip_address: Optional[str] = None):
|
| 417 |
now = _now_iso()
|
| 418 |
parsed = _parse_user_agent(user_agent or "")
|
| 419 |
with self._lock:
|
| 420 |
+
import asyncio
|
| 421 |
+
asyncio.run(self._get_client().insert("url_shortener_clicks", {
|
| 422 |
+
"short_code": short_code,
|
| 423 |
+
"referrer": referrer,
|
| 424 |
+
"user_agent": user_agent,
|
| 425 |
+
"ip_address": ip_address,
|
| 426 |
+
"country": None,
|
| 427 |
+
"browser": parsed["browser"],
|
| 428 |
+
"device": parsed["device"],
|
| 429 |
+
"os": parsed["os"],
|
| 430 |
+
"clicked_at": now,
|
| 431 |
+
}))
|
| 432 |
+
link = asyncio.run(self._get_client().find_one("url_shortener_links", "short_code", short_code))
|
| 433 |
+
if link:
|
| 434 |
+
current_clicks = (link.get("click_count") or 0) + 1
|
| 435 |
+
asyncio.run(self._get_client().update(
|
| 436 |
+
"url_shortener_links", "short_code", short_code,
|
| 437 |
+
{"click_count": current_clicks, "last_accessed_at": now},
|
| 438 |
+
))
|
| 439 |
|
| 440 |
def get_click_analytics(self, short_code: str) -> Dict[str, Any]:
|
| 441 |
+
import asyncio
|
| 442 |
+
client = self._get_client()
|
| 443 |
+
all_clicks = asyncio.run(client.select("url_shortener_clicks", eq=("short_code", short_code)))
|
| 444 |
+
total_val = len(all_clicks)
|
| 445 |
+
browsers: Dict[str, int] = {}
|
| 446 |
+
devices: Dict[str, int] = {}
|
| 447 |
+
referrers: Dict[str, int] = {}
|
| 448 |
+
os_data: Dict[str, int] = {}
|
| 449 |
+
recent = []
|
| 450 |
+
for c in all_clicks:
|
| 451 |
+
b = c.get("browser")
|
| 452 |
+
if b:
|
| 453 |
+
browsers[b] = browsers.get(b, 0) + 1
|
| 454 |
+
d = c.get("device")
|
| 455 |
+
if d:
|
| 456 |
+
devices[d] = devices.get(d, 0) + 1
|
| 457 |
+
o = c.get("os")
|
| 458 |
+
if o:
|
| 459 |
+
os_data[o] = os_data.get(o, 0) + 1
|
| 460 |
+
ref = c.get("referrer") or "(direct)"
|
| 461 |
+
referrers[ref] = referrers.get(ref, 0) + 1
|
| 462 |
+
recent.append(c.get("clicked_at"))
|
| 463 |
+
top_referrers = dict(sorted(referrers.items(), key=lambda x: -x[1])[:10])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 464 |
return {
|
| 465 |
"total_clicks": total_val,
|
| 466 |
+
"browsers": browsers,
|
| 467 |
+
"devices": devices,
|
| 468 |
+
"operating_systems": os_data,
|
| 469 |
+
"top_referrers": top_referrers,
|
| 470 |
+
"recent_clicks": recent[:50],
|
| 471 |
}
|
| 472 |
|
| 473 |
def get_link_count_for_owner(self, owner_id: str) -> int:
|
| 474 |
+
import asyncio
|
| 475 |
+
links = asyncio.run(self._get_client().select("url_shortener_links", eq=("owner_id", owner_id)))
|
| 476 |
+
return len(links)
|
|
|
|
|
|
|
| 477 |
|
| 478 |
def audit(self, owner_id: Optional[str], action: str, short_code: Optional[str], detail: str = ""):
|
| 479 |
with self._lock:
|
| 480 |
+
import asyncio
|
| 481 |
+
asyncio.run(self._get_client().insert("url_shortener_audit_log", {
|
| 482 |
+
"timestamp": _now_iso(),
|
| 483 |
+
"owner_id": owner_id,
|
| 484 |
+
"action": action,
|
| 485 |
+
"short_code": short_code,
|
| 486 |
+
"detail": detail,
|
| 487 |
+
}))
|
| 488 |
|
| 489 |
def export_links_csv(self, owner_id: str) -> str:
|
| 490 |
+
import asyncio
|
| 491 |
+
rows = asyncio.run(self._get_client().select(
|
| 492 |
+
"url_shortener_links", eq=("owner_id", owner_id), order=("created_at", True),
|
|
|
|
| 493 |
))
|
| 494 |
buf = io.StringIO()
|
| 495 |
w = csv.writer(buf)
|
| 496 |
w.writerow(["short_code", "long_url", "created_at", "click_count", "is_active", "tags", "note"])
|
| 497 |
for r in rows:
|
| 498 |
w.writerow([r["short_code"], r["long_url"], r["created_at"],
|
| 499 |
+
r["click_count"], r["is_active"], r.get("tags") or "", r.get("note") or ""])
|
| 500 |
return buf.getvalue()
|
| 501 |
|
| 502 |
def export_clicks_csv(self, short_code: str) -> str:
|
| 503 |
+
import asyncio
|
| 504 |
+
rows = asyncio.run(self._get_client().select(
|
| 505 |
+
"url_shortener_clicks", eq=("short_code", short_code), order=("clicked_at", True),
|
|
|
|
| 506 |
))
|
| 507 |
buf = io.StringIO()
|
| 508 |
w = csv.writer(buf)
|
| 509 |
w.writerow(["id", "referrer", "user_agent", "ip_address", "browser", "device", "os", "clicked_at"])
|
| 510 |
for r in rows:
|
| 511 |
+
w.writerow([r["id"], r.get("referrer"), r.get("user_agent"), r.get("ip_address"),
|
| 512 |
+
r.get("browser"), r.get("device"), r.get("os"), r.get("clicked_at")])
|
| 513 |
return buf.getvalue()
|
| 514 |
|
| 515 |
def list_links_by_campaign(self, campaign_id: str) -> List[dict]:
|
| 516 |
+
import asyncio
|
| 517 |
+
return asyncio.run(self._get_client().select(
|
| 518 |
+
"url_shortener_links", eq=("campaign_id", campaign_id), order=("created_at", True),
|
| 519 |
))
|
| 520 |
|
| 521 |
def create_campaign(self, campaign_id: str, owner_id: str, name: str, description: Optional[str] = None):
|
| 522 |
with self._lock:
|
| 523 |
+
import asyncio
|
| 524 |
+
asyncio.run(self._get_client().insert("url_shortener_campaigns", {
|
| 525 |
+
"campaign_id": campaign_id,
|
| 526 |
+
"owner_id": owner_id,
|
| 527 |
+
"name": name,
|
| 528 |
+
"description": description,
|
| 529 |
+
"created_at": _now_iso(),
|
| 530 |
+
}))
|
| 531 |
|
| 532 |
def get_campaign(self, campaign_id: str) -> Optional[dict]:
|
| 533 |
+
import asyncio
|
| 534 |
+
return asyncio.run(self._get_client().find_one("url_shortener_campaigns", "campaign_id", campaign_id))
|
|
|
|
|
|
|
| 535 |
|
| 536 |
def list_campaigns_for_owner(self, owner_id: str) -> List[dict]:
|
| 537 |
+
import asyncio
|
| 538 |
+
return asyncio.run(self._get_client().select(
|
| 539 |
+
"url_shortener_campaigns", eq=("owner_id", owner_id), order=("created_at", True),
|
| 540 |
))
|
| 541 |
|
| 542 |
def deactivate_campaign(self, campaign_id: str):
|
| 543 |
with self._lock:
|
| 544 |
+
import asyncio
|
| 545 |
+
asyncio.run(self._get_client().update("url_shortener_campaigns", "campaign_id", campaign_id, {"is_active": 0}))
|
|
|
|
|
|
|
| 546 |
|
| 547 |
def get_campaign_link_count(self, campaign_id: str) -> int:
|
| 548 |
+
import asyncio
|
| 549 |
+
links = asyncio.run(self._get_client().select("url_shortener_links", eq=("campaign_id", campaign_id)))
|
| 550 |
+
return len(links)
|
|
|
|
|
|
|
| 551 |
|
| 552 |
def get_campaign_total_clicks(self, campaign_id: str) -> int:
|
| 553 |
+
import asyncio
|
| 554 |
+
links = asyncio.run(self._get_client().select("url_shortener_links", eq=("campaign_id", campaign_id)))
|
| 555 |
+
return sum(l.get("click_count") or 0 for l in links)
|
|
|
|
|
|
|
| 556 |
|
| 557 |
def get_campaign_analytics(self, campaign_id: str) -> Dict[str, Any]:
|
| 558 |
+
import asyncio
|
| 559 |
+
client = self._get_client()
|
| 560 |
+
codes = asyncio.run(client.select("url_shortener_links", eq=("campaign_id", campaign_id)))
|
|
|
|
| 561 |
total = 0
|
| 562 |
browsers: Dict[str, int] = {}
|
| 563 |
devices: Dict[str, int] = {}
|
| 564 |
os_data: Dict[str, int] = {}
|
|
|
|
| 565 |
for row in codes:
|
| 566 |
code = row["short_code"]
|
| 567 |
+
clicks = asyncio.run(client.select("url_shortener_clicks", eq=("short_code", code)))
|
| 568 |
+
total += len(clicks)
|
| 569 |
+
for c in clicks:
|
| 570 |
+
b = c.get("browser")
|
| 571 |
+
if b:
|
| 572 |
+
browsers[b] = browsers.get(b, 0) + 1
|
| 573 |
+
d = c.get("device")
|
| 574 |
+
if d:
|
| 575 |
+
devices[d] = devices.get(d, 0) + 1
|
| 576 |
+
o = c.get("os")
|
| 577 |
+
if o:
|
| 578 |
+
os_data[o] = os_data.get(o, 0) + 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 579 |
return {"total_clicks": total, "browsers": browsers, "devices": devices, "operating_systems": os_data}
|
| 580 |
|
| 581 |
def owner_summary(self, owner_id: str) -> Dict[str, Any]:
|
| 582 |
row = self.get_owner(owner_id)
|
| 583 |
if row is None:
|
| 584 |
return {}
|
| 585 |
+
import asyncio
|
| 586 |
+
client = self._get_client()
|
| 587 |
+
links = asyncio.run(client.select("url_shortener_links", eq=("owner_id", owner_id)))
|
| 588 |
+
link_count = len(links)
|
| 589 |
+
total_clicks = sum(l.get("click_count") or 0 for l in links)
|
| 590 |
+
active = sum(1 for l in links if l.get("is_active"))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 591 |
return {
|
| 592 |
"owner_id": owner_id,
|
| 593 |
"name": row["name"],
|
| 594 |
"plan": row["plan"],
|
| 595 |
"total_links": link_count,
|
| 596 |
+
"active_links": active,
|
| 597 |
"total_clicks": total_clicks,
|
| 598 |
"created_at": row["created_at"],
|
| 599 |
}
|
|
|
|
| 603 |
def __init__(self):
|
| 604 |
self.storage = Storage()
|
| 605 |
self.storage._init_schema()
|
| 606 |
+
logger.info("URLShortenerService initialized (Supabase)")
|
| 607 |
|
| 608 |
def close(self) -> None:
|
| 609 |
self.storage.close()
|
|
|
|
| 949 |
raise LinkNotFoundError(f"No link found for code '{short_code}'")
|
| 950 |
if row["owner_id"] != owner_id:
|
| 951 |
raise AuthorizationError("You do not own this link")
|
| 952 |
+
import asyncio
|
| 953 |
+
client = get_supabase_client()
|
| 954 |
+
if client is None:
|
| 955 |
return []
|
| 956 |
+
rows = asyncio.run(client.select(
|
| 957 |
+
"url_shortener_clicks", eq=("short_code", short_code),
|
|
|
|
|
|
|
| 958 |
))
|
| 959 |
+
return list(rows)[:limit]
|
| 960 |
|
| 961 |
def get_qr_code(self, short_code: str) -> Dict[str, str]:
|
| 962 |
row = self.storage.get_link(short_code)
|
app/services/vector_store_service.py
CHANGED
|
@@ -14,7 +14,7 @@ import zvec
|
|
| 14 |
|
| 15 |
from app.config import get_settings
|
| 16 |
from app.core.logger import get_logger
|
| 17 |
-
from app.
|
| 18 |
from app.services.chunking_service import chunk_text_async
|
| 19 |
from app.services.embeddings_service import EmbeddingService
|
| 20 |
|
|
@@ -62,27 +62,26 @@ class VectorStoreService:
|
|
| 62 |
def _get_collection(self, store_id: str) -> Optional["zvec.Collection"]:
|
| 63 |
return self._collections.get(store_id)
|
| 64 |
|
| 65 |
-
# ---
|
| 66 |
|
| 67 |
async def init_db(self) -> None:
|
| 68 |
-
|
| 69 |
-
if
|
| 70 |
-
logger.warning("
|
| 71 |
return
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
for row in rows:
|
| 76 |
record = VectorStoreRecord(
|
| 77 |
-
store_id=
|
| 78 |
-
name=
|
| 79 |
-
path=
|
| 80 |
-
description=
|
| 81 |
-
metadata=json.loads(
|
| 82 |
-
created_at=
|
| 83 |
)
|
| 84 |
self._stores[record.store_id] = record
|
| 85 |
-
store_path =
|
| 86 |
if os.path.exists(os.path.join(store_path, "__zvec_meta")):
|
| 87 |
try:
|
| 88 |
col = zvec.open(store_path)
|
|
@@ -92,39 +91,28 @@ class VectorStoreService:
|
|
| 92 |
logger.warning("Could not open collection %s: %s", record.store_id, exc)
|
| 93 |
|
| 94 |
async def _persist_store(self, record: VectorStoreRecord) -> None:
|
| 95 |
-
|
| 96 |
-
if
|
| 97 |
-
logger.error("
|
| 98 |
return
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
)
|
| 103 |
-
|
| 104 |
-
await mgr.execute(
|
| 105 |
-
"UPDATE vector_store_index SET name = %s, description = %s, "
|
| 106 |
-
"metadata_json = %s WHERE store_id = %s",
|
| 107 |
-
(record.name, record.description, json.dumps(record.metadata), record.store_id),
|
| 108 |
-
)
|
| 109 |
-
else:
|
| 110 |
-
await mgr.execute(
|
| 111 |
-
"INSERT INTO vector_store_index (store_id, name, path, description, metadata_json, created_at) "
|
| 112 |
-
"VALUES (%s, %s, %s, %s, %s, %s)",
|
| 113 |
-
(
|
| 114 |
-
record.store_id, record.name, record.path,
|
| 115 |
-
record.description, json.dumps(record.metadata),
|
| 116 |
-
record.created_at,
|
| 117 |
-
),
|
| 118 |
-
)
|
| 119 |
|
| 120 |
async def _remove_persisted_store(self, store_id: str) -> None:
|
| 121 |
-
|
| 122 |
-
if
|
| 123 |
return
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
(store_id,),
|
| 127 |
-
)
|
| 128 |
|
| 129 |
# --- Synchronous helpers (run in thread pool) ---
|
| 130 |
|
|
@@ -358,7 +346,6 @@ class VectorStoreService:
|
|
| 358 |
"store_id": store_id,
|
| 359 |
"name": record.name,
|
| 360 |
"description": record.description,
|
| 361 |
-
"app_id": settings.application_id or store_id,
|
| 362 |
"embedding_dimension": _EMBEDDING_DIM,
|
| 363 |
"document_count": doc_count,
|
| 364 |
"created_at": record.created_at,
|
|
@@ -376,7 +363,7 @@ class VectorStoreService:
|
|
| 376 |
name: str,
|
| 377 |
description: str = "",
|
| 378 |
metadata: Optional[Dict[str, Any]] = None,
|
| 379 |
-
) ->
|
| 380 |
store_id = str(uuid.uuid4())
|
| 381 |
store_path = self._store_path(store_id)
|
| 382 |
|
|
@@ -394,9 +381,8 @@ class VectorStoreService:
|
|
| 394 |
self._stores[store_id] = record
|
| 395 |
await self._persist_store(record)
|
| 396 |
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
return store_id, app_id
|
| 400 |
|
| 401 |
def list_stores(self) -> List[VectorStoreRecord]:
|
| 402 |
return list(self._stores.values())
|
|
|
|
| 14 |
|
| 15 |
from app.config import get_settings
|
| 16 |
from app.core.logger import get_logger
|
| 17 |
+
from app.services.supabase import get_supabase_client, VectorStoreRepository
|
| 18 |
from app.services.chunking_service import chunk_text_async
|
| 19 |
from app.services.embeddings_service import EmbeddingService
|
| 20 |
|
|
|
|
| 62 |
def _get_collection(self, store_id: str) -> Optional["zvec.Collection"]:
|
| 63 |
return self._collections.get(store_id)
|
| 64 |
|
| 65 |
+
# --- Supabase persistence ---
|
| 66 |
|
| 67 |
async def init_db(self) -> None:
|
| 68 |
+
client = get_supabase_client()
|
| 69 |
+
if client is None:
|
| 70 |
+
logger.warning("Supabase not available, vector store metadata not loaded")
|
| 71 |
return
|
| 72 |
+
repo = VectorStoreRepository(client)
|
| 73 |
+
indexes = await repo.find_all()
|
| 74 |
+
for index in indexes:
|
|
|
|
| 75 |
record = VectorStoreRecord(
|
| 76 |
+
store_id=index.store_id,
|
| 77 |
+
name=index.name,
|
| 78 |
+
path=index.path,
|
| 79 |
+
description=index.description,
|
| 80 |
+
metadata=json.loads(index.metadata_json or "{}"),
|
| 81 |
+
created_at=index.created_at,
|
| 82 |
)
|
| 83 |
self._stores[record.store_id] = record
|
| 84 |
+
store_path = index.path
|
| 85 |
if os.path.exists(os.path.join(store_path, "__zvec_meta")):
|
| 86 |
try:
|
| 87 |
col = zvec.open(store_path)
|
|
|
|
| 91 |
logger.warning("Could not open collection %s: %s", record.store_id, exc)
|
| 92 |
|
| 93 |
async def _persist_store(self, record: VectorStoreRecord) -> None:
|
| 94 |
+
client = get_supabase_client()
|
| 95 |
+
if client is None:
|
| 96 |
+
logger.error("Supabase not available, cannot persist store")
|
| 97 |
return
|
| 98 |
+
repo = VectorStoreRepository(client)
|
| 99 |
+
from app.core.vector_store.models import VectorStoreIndex
|
| 100 |
+
index = VectorStoreIndex(
|
| 101 |
+
store_id=record.store_id,
|
| 102 |
+
name=record.name,
|
| 103 |
+
path=record.path,
|
| 104 |
+
description=record.description,
|
| 105 |
+
metadata_json=json.dumps(record.metadata),
|
| 106 |
+
created_at=record.created_at,
|
| 107 |
)
|
| 108 |
+
await repo.upsert(index)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
async def _remove_persisted_store(self, store_id: str) -> None:
|
| 111 |
+
client = get_supabase_client()
|
| 112 |
+
if client is None:
|
| 113 |
return
|
| 114 |
+
repo = VectorStoreRepository(client)
|
| 115 |
+
await repo.delete(store_id)
|
|
|
|
|
|
|
| 116 |
|
| 117 |
# --- Synchronous helpers (run in thread pool) ---
|
| 118 |
|
|
|
|
| 346 |
"store_id": store_id,
|
| 347 |
"name": record.name,
|
| 348 |
"description": record.description,
|
|
|
|
| 349 |
"embedding_dimension": _EMBEDDING_DIM,
|
| 350 |
"document_count": doc_count,
|
| 351 |
"created_at": record.created_at,
|
|
|
|
| 363 |
name: str,
|
| 364 |
description: str = "",
|
| 365 |
metadata: Optional[Dict[str, Any]] = None,
|
| 366 |
+
) -> str:
|
| 367 |
store_id = str(uuid.uuid4())
|
| 368 |
store_path = self._store_path(store_id)
|
| 369 |
|
|
|
|
| 381 |
self._stores[store_id] = record
|
| 382 |
await self._persist_store(record)
|
| 383 |
|
| 384 |
+
logger.info("Created vector store: %s (name=%s)", store_id, name)
|
| 385 |
+
return store_id
|
|
|
|
| 386 |
|
| 387 |
def list_stores(self) -> List[VectorStoreRecord]:
|
| 388 |
return list(self._stores.values())
|
pyproject.toml
CHANGED
|
@@ -32,6 +32,7 @@ dependencies = [
|
|
| 32 |
"spacy>=3.7.0",
|
| 33 |
"phonenumbers>=8.13.0",
|
| 34 |
"jsonschema>=4.21.0",
|
|
|
|
| 35 |
]
|
| 36 |
|
| 37 |
[project.optional-dependencies]
|
|
|
|
| 32 |
"spacy>=3.7.0",
|
| 33 |
"phonenumbers>=8.13.0",
|
| 34 |
"jsonschema>=4.21.0",
|
| 35 |
+
"supabase>=2.0.0",
|
| 36 |
]
|
| 37 |
|
| 38 |
[project.optional-dependencies]
|
requirements.txt
CHANGED
|
@@ -39,7 +39,7 @@ email-validator>=2.1.0
|
|
| 39 |
slowapi>=0.1.9
|
| 40 |
|
| 41 |
# Async database drivers
|
| 42 |
-
|
| 43 |
asyncpg>=0.31.0
|
| 44 |
motor>=3.7.1
|
| 45 |
|
|
|
|
| 39 |
slowapi>=0.1.9
|
| 40 |
|
| 41 |
# Async database drivers
|
| 42 |
+
supabase>=2.0.0
|
| 43 |
asyncpg>=0.31.0
|
| 44 |
motor>=3.7.1
|
| 45 |
|