Files
saas_backend/app/routes/system/document.py
T
2026-08-31 20:39:41 -04:00

221 lines
7.5 KiB
Python

"""Uploading, listing and downloading attachments.
## Why downloads go through here rather than through a static path
A file served from a directory a web server exposes is a URL: anyone who has it
can fetch it, for ever, from any workspace. Serving through the API means the
session, the workspace and the permission are checked on every read — and a
storage key that leaked in a log is still worth nothing.
## The headers, which are the actual security control
- **`Content-Type` is what we determined**, from the bytes, never what the
uploader claimed.
- **`X-Content-Type-Options: nosniff`** so a browser does not go looking for a
better idea than the one we gave it. Without it, `text/plain` containing HTML
is rendered as HTML by some browsers, from this origin.
- **`Content-Disposition: attachment`** for everything a browser might execute.
Only formats it cannot — PDF, PNG, JPEG, GIF, WebP — are served `inline`, which
is the difference between looking at an invoice and downloading it first.
- **`Content-Security-Policy: sandbox`** as the belt to that braces: even if the
type were somehow wrong, a sandboxed response has no origin to attack.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import List, Optional
from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
Request,
UploadFile,
status,
)
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict
from sqlalchemy.orm import Session
from app.config.database import get_db
from app.config.settings import settings
from app.core import document_storage, file_types
from app.helper.helpers import get_client_ip
from app.middleware.auth_middleware import User, get_current_user, require_access
from app.models.system.document_model import Document
from app.services.system import document_service
from app.services.system.audit_log_service import AuditLogService
router = APIRouter()
class DocumentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
filename: str
content_type: str
size_bytes: int
entity_type: Optional[str] = None
entity_id: Optional[str] = None
description: Optional[str] = None
uploaded_by_id: Optional[uuid.UUID] = None
created_at: datetime
class StorageUsage(BaseModel):
used_bytes: int
quota_bytes: int
max_upload_bytes: int
def _workspace(user: User) -> uuid.UUID:
if user.tenant_id is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Account is not associated with a workspace")
return user.tenant_id
@router.get("/usage", response_model=StorageUsage)
def usage(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.document.read")),
):
"""How much room is left.
Its own route because a screen shows it before somebody picks a file, and
finding out after the upload that there was no room is the worst moment to
find out.
"""
return StorageUsage(
used_bytes=document_service.used_bytes(db, _workspace(current_user)),
quota_bytes=settings.DOCUMENT_QUOTA_BYTES,
max_upload_bytes=settings.DOCUMENT_MAX_BYTES,
)
@router.get("", response_model=List[DocumentResponse])
def list_documents(
entity_type: str,
entity_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.document.read")),
):
"""Everything attached to one record.
Both parameters are required: a list of every document in a workspace is a
different screen with different pagination, and answering it here by
accident — because somebody omitted a filter — is how a listing turns into a
dump.
"""
rows = document_service.for_entity(
db, _workspace(current_user), entity_type, entity_id
)
return [DocumentResponse.model_validate(row) for row in rows]
@router.post("", response_model=DocumentResponse,
status_code=status.HTTP_201_CREATED)
def upload(
request: Request,
file: UploadFile = File(...),
entity_type: Optional[str] = Form(None),
entity_id: Optional[str] = Form(None),
description: Optional[str] = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.document.write")),
):
document = document_service.upload(
db,
tenant_id=_workspace(current_user),
uploaded_by=current_user,
filename=file.filename or "upload",
source=file.file,
entity_type=entity_type,
entity_id=entity_id,
description=description,
)
AuditLogService.log(
db=db, module_name="Documents", action_type="CREATE",
entity_id=str(document.id), entity_name=document.filename,
description="Document '" + document.filename + "' uploaded",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
tenant_id=current_user.tenant_id,
new_values={
"content_type": document.content_type,
"size_bytes": document.size_bytes,
"checksum": document.checksum,
},
)
db.commit()
db.refresh(document)
return DocumentResponse.model_validate(document)
@router.get("/{document_id}/content")
def download(
document_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.document.read")),
):
document = document_service.get(db, _workspace(current_user), document_id)
if not document_storage.exists(document.storage_key):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="That file is no longer available")
disposition = file_types.disposition_for(document.content_type)
safe_name = file_types.safe_filename(document.filename)
return StreamingResponse(
document_storage.read(document.storage_key),
media_type=document.content_type,
headers={
"Content-Disposition": f'{disposition}; filename="{safe_name}"',
"Content-Length": str(document.size_bytes),
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "sandbox; default-src 'none'",
"Cache-Control": "private, max-age=0, no-store",
},
)
@router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove(
document_id: uuid.UUID,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.document.write")),
):
"""The bytes go; the record stays.
Storage costs money and a deleted file should stop costing it — but "who
removed that attachment, and when" is exactly what gets asked afterwards.
"""
document = document_service.get(db, _workspace(current_user), document_id)
name = document.filename
document_service.delete(db, document, current_user)
AuditLogService.log(
db=db, module_name="Documents", action_type="DELETE",
entity_id=str(document_id), entity_name=name,
description="Document '" + name + "' deleted",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
tenant_id=current_user.tenant_id,
)
db.commit()