fec3953009
- Three category scopes: personal / group / system (watch) - PascalCase-with-dashes naming convention enforced at backend + frontend - is_group_admin flag on GroupMembership; PATCH endpoint for admins to toggle it - Categories router: scope-based list/create/rename/delete with _check_can_manage_cat - Documents router: delete uses is_admin + can_delete share flag + group-admin check; remove_category requires doc ownership; assign_category accepts group/system categories - Proxy layers inject x-user-is-admin and x-user-admin-groups headers - Frontend: ManageCategoriesDialog grouped by scope with lock icons; SourcePanel scope picker + client-side name validation; AdminGroupsPage group-admin checkbox Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
114 lines
3.7 KiB
Python
114 lines
3.7 KiB
Python
"""
|
|
Proxy all /api/documents/* requests to doc-service:8001/documents/*.
|
|
|
|
Uses a module-level AsyncClient for connection pooling.
|
|
Strips hop-by-hop headers that must not be forwarded.
|
|
Injects X-User-Id and X-User-Groups headers so the doc-service
|
|
can enforce ownership and group-sharing access without querying the
|
|
backend database directly.
|
|
"""
|
|
import os
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import Response
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.deps import get_current_user
|
|
from app.models.group import GroupMembership
|
|
from app.models.user import User
|
|
|
|
DOC_SERVICE_URL = os.environ.get("DOC_SERVICE_URL", "http://doc-service:8001")
|
|
|
|
_client = httpx.AsyncClient(base_url=DOC_SERVICE_URL, timeout=120.0)
|
|
|
|
router = APIRouter()
|
|
|
|
# Headers that must not be forwarded in either direction.
|
|
# Also strip accept-encoding so doc-service never compresses responses —
|
|
# httpx decompresses transparently but the content-encoding header would
|
|
# then mismatch the already-decompressed body we forward to the browser.
|
|
_HOP_BY_HOP = frozenset([
|
|
"connection",
|
|
"keep-alive",
|
|
"proxy-authenticate",
|
|
"proxy-authorization",
|
|
"te",
|
|
"trailers",
|
|
"transfer-encoding",
|
|
"upgrade",
|
|
"host",
|
|
"accept-encoding",
|
|
])
|
|
|
|
# Additional response headers we let FastAPI recalculate rather than forward.
|
|
# content-length is set automatically from the response body size.
|
|
# content-type is set via the media_type argument, so strip it from headers
|
|
# to avoid duplicates.
|
|
_STRIP_RESPONSE = frozenset([*_HOP_BY_HOP, "content-length", "content-type"])
|
|
|
|
|
|
async def _forward_headers(
|
|
request: Request, user_id: str, is_admin: bool, db: AsyncSession
|
|
) -> dict:
|
|
headers = {
|
|
k: v
|
|
for k, v in request.headers.items()
|
|
if k.lower() not in _HOP_BY_HOP
|
|
}
|
|
headers["x-user-id"] = user_id
|
|
headers["x-user-is-admin"] = "true" if is_admin else "false"
|
|
|
|
# Inject group memberships and group-admin status so the doc-service can
|
|
# evaluate ownership, sharing access, and group-admin permissions.
|
|
mem_result = await db.execute(
|
|
select(GroupMembership.group_id, GroupMembership.is_group_admin)
|
|
.where(GroupMembership.user_id == user_id)
|
|
)
|
|
rows = mem_result.all()
|
|
group_ids = [row[0] for row in rows]
|
|
admin_group_ids = [row[0] for row in rows if row[1]]
|
|
headers["x-user-groups"] = ",".join(group_ids)
|
|
headers["x-user-admin-groups"] = ",".join(admin_group_ids)
|
|
|
|
return headers
|
|
|
|
|
|
@router.api_route("", methods=["GET", "POST"])
|
|
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
|
|
async def proxy_documents(
|
|
request: Request,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
path: str = "",
|
|
) -> Response:
|
|
url = f"/documents/{path}" if path else "/documents"
|
|
headers = await _forward_headers(request, str(current_user.id), current_user.is_superuser, db)
|
|
body = await request.body()
|
|
|
|
try:
|
|
response = await _client.request(
|
|
method=request.method,
|
|
url=url,
|
|
headers=headers,
|
|
content=body,
|
|
params=dict(request.query_params),
|
|
)
|
|
except httpx.RequestError as exc:
|
|
raise HTTPException(status_code=502, detail=f"doc-service unreachable: {exc}")
|
|
|
|
resp_headers = {
|
|
k: v
|
|
for k, v in response.headers.items()
|
|
if k.lower() not in _STRIP_RESPONSE
|
|
}
|
|
|
|
return Response(
|
|
content=response.content,
|
|
status_code=response.status_code,
|
|
headers=resp_headers,
|
|
media_type=response.headers.get("content-type"),
|
|
)
|