2025-10-18 06:14:03 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2025-10-26 04:14:05 +00:00
|
|
|
from typing import AsyncGenerator, Optional
|
2025-10-18 06:14:03 +00:00
|
|
|
|
|
|
|
|
import aiosqlite
|
2025-11-01 10:21:53 +00:00
|
|
|
from fastapi import Depends, HTTPException, Request
|
2025-10-18 06:14:03 +00:00
|
|
|
from fastapi.responses import JSONResponse
|
|
|
|
|
|
|
|
|
|
import db
|
|
|
|
|
from common import ProblemDetails
|
|
|
|
|
from settings import settings
|
2025-11-01 02:43:26 +00:00
|
|
|
from users.models import User
|
2025-11-01 03:14:58 +00:00
|
|
|
from security import JwtConfig, verify_jwt
|
2025-11-01 02:51:08 +00:00
|
|
|
from typing import TypedDict
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class HouseholdCtx(TypedDict):
|
|
|
|
|
id: int
|
|
|
|
|
slug: str
|
2025-10-18 06:14:03 +00:00
|
|
|
|
|
|
|
|
|
2025-10-19 02:12:28 +00:00
|
|
|
# Dependency to create SQLite connection with PRAGMAs and per-request transaction
|
2025-10-18 06:14:03 +00:00
|
|
|
async def get_db() -> AsyncGenerator[aiosqlite.Connection, None]:
|
|
|
|
|
sql_db = await db.connect(settings.database_path)
|
2025-10-19 02:12:28 +00:00
|
|
|
# Connection-level configuration
|
2025-10-18 06:14:03 +00:00
|
|
|
try:
|
2025-10-19 02:12:28 +00:00
|
|
|
# Enable FK enforcement
|
|
|
|
|
await sql_db.execute("PRAGMA foreign_keys=ON;")
|
|
|
|
|
# Prefer WAL for better concurrency; ignore result
|
|
|
|
|
async with sql_db.execute("PRAGMA journal_mode=WAL;") as _:
|
|
|
|
|
await _.fetchone()
|
|
|
|
|
# Reasonable durability/perf tradeoff
|
|
|
|
|
await sql_db.execute("PRAGMA synchronous=NORMAL;")
|
|
|
|
|
# Begin a transaction for the whole request
|
|
|
|
|
await sql_db.execute("BEGIN;")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
yield sql_db
|
|
|
|
|
await sql_db.commit()
|
|
|
|
|
except Exception:
|
|
|
|
|
await sql_db.rollback()
|
|
|
|
|
raise
|
2025-10-18 06:14:03 +00:00
|
|
|
finally:
|
|
|
|
|
await sql_db.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def error_response(request: Optional[Request], status_code: int, message: str) -> JSONResponse:
|
|
|
|
|
body = ProblemDetails(
|
|
|
|
|
title=message,
|
|
|
|
|
status=status_code,
|
|
|
|
|
type=f"https://httpstatuses.com/{status_code}",
|
|
|
|
|
instance=str(request.url) if request else None,
|
|
|
|
|
)
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
content=body.model_dump(by_alias=True),
|
|
|
|
|
status_code=status_code,
|
|
|
|
|
media_type="application/problem+json",
|
|
|
|
|
)
|
2025-11-01 02:43:26 +00:00
|
|
|
|
|
|
|
|
|
2025-11-01 03:14:58 +00:00
|
|
|
def _jwt_config() -> JwtConfig:
|
|
|
|
|
import base64
|
|
|
|
|
|
|
|
|
|
if settings.access_secret_b64:
|
|
|
|
|
access = base64.b64decode(settings.access_secret_b64)
|
|
|
|
|
else:
|
|
|
|
|
access = b"dev-access-secret-change-me-32bytes!!"[:32]
|
|
|
|
|
if settings.refresh_secret_b64:
|
|
|
|
|
refresh = base64.b64decode(settings.refresh_secret_b64)
|
|
|
|
|
else:
|
|
|
|
|
refresh = b"dev-refresh-secret-change-me-32bytes!!"[:32]
|
|
|
|
|
return JwtConfig(
|
|
|
|
|
issuer=settings.jwt_issuer,
|
|
|
|
|
audience=settings.jwt_audience,
|
|
|
|
|
access_secret=access,
|
|
|
|
|
refresh_secret=refresh,
|
|
|
|
|
access_ttl_seconds=settings.access_ttl_seconds,
|
|
|
|
|
refresh_ttl_seconds=settings.refresh_ttl_seconds,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-11-01 02:43:26 +00:00
|
|
|
async def get_current_user(request: Request, conn: aiosqlite.Connection = Depends(get_db)) -> User:
|
2025-11-01 03:14:58 +00:00
|
|
|
"""JWT bearer auth: expects Authorization: Bearer <JWT> with sub=user id.
|
2025-11-01 02:43:26 +00:00
|
|
|
|
2025-11-01 03:14:58 +00:00
|
|
|
Returns 401 on failure.
|
2025-11-01 02:43:26 +00:00
|
|
|
"""
|
|
|
|
|
auth = request.headers.get("Authorization")
|
|
|
|
|
if not auth or not auth.lower().startswith("bearer "):
|
|
|
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
|
|
|
token = auth.split(" ", 1)[1].strip()
|
|
|
|
|
try:
|
2025-11-01 03:14:58 +00:00
|
|
|
_h, payload = verify_jwt(_jwt_config(), token, expected_kind="access")
|
|
|
|
|
user_id = int(str(payload.get("sub")))
|
2025-11-01 02:43:26 +00:00
|
|
|
except Exception:
|
|
|
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
|
|
|
# Lookup by id
|
|
|
|
|
async with conn.execute(
|
|
|
|
|
"SELECT id, email, display_name, profile_photo_url FROM User WHERE id = ?",
|
|
|
|
|
(user_id,),
|
|
|
|
|
) as c:
|
|
|
|
|
row = await c.fetchone()
|
|
|
|
|
if not row:
|
|
|
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
|
|
|
return User(id=int(row[0]), email=row[1], display_name=row[2], profile_photo_url=row[3])
|
2025-11-01 02:51:08 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_household_from_slug(
|
|
|
|
|
request: Request,
|
|
|
|
|
householdSlug: str, # path parameter
|
|
|
|
|
user: User = Depends(get_current_user),
|
|
|
|
|
conn: aiosqlite.Connection = Depends(get_db),
|
|
|
|
|
) -> HouseholdCtx:
|
|
|
|
|
# Find household by slug
|
|
|
|
|
async with conn.execute(
|
|
|
|
|
"SELECT id, slug FROM Household WHERE slug = ? LIMIT 1",
|
|
|
|
|
(householdSlug,),
|
|
|
|
|
) as c:
|
|
|
|
|
row = await c.fetchone()
|
|
|
|
|
if not row:
|
|
|
|
|
# 404 to avoid leaking membership existence
|
|
|
|
|
raise HTTPException(status_code=404, detail="Household not found")
|
|
|
|
|
hid = int(row[0])
|
|
|
|
|
# Verify membership
|
|
|
|
|
async with conn.execute(
|
|
|
|
|
"SELECT 1 FROM HouseholdMember WHERE user_id = ? AND household_id = ? LIMIT 1",
|
|
|
|
|
(user.id, hid),
|
|
|
|
|
) as c:
|
|
|
|
|
m = await c.fetchone()
|
|
|
|
|
if not m:
|
|
|
|
|
raise HTTPException(status_code=403, detail="Forbidden")
|
|
|
|
|
return {"id": hid, "slug": row[1]}
|